diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..cdb36c1b --- /dev/null +++ b/.editorconfig @@ -0,0 +1,11 @@ +# http://editorconfig.org +root = true + +[*] +charset = utf-8 +insert_final_newline = true +trim_trailing_whitespace = true + +[{*.js,*.json,*.yml}] +indent_size = 2 +indent_style = space diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 00000000..76b1021d --- /dev/null +++ b/.eslintignore @@ -0,0 +1,3 @@ +.nyc_output +coverage +node_modules diff --git a/.eslintrc.yml b/.eslintrc.yml new file mode 100644 index 00000000..b6b9f62f --- /dev/null +++ b/.eslintrc.yml @@ -0,0 +1,15 @@ +root: true +extends: + - plugin:markdown/recommended +plugins: + - markdown +overrides: + - files: '**/*.md' + processor: 'markdown/markdown' +rules: + eol-last: error + eqeqeq: ["error", "always", { "null": "ignore" }] + indent: ["error", 2, { "MemberExpression": "off", "SwitchCase": 1 }] + no-mixed-spaces-and-tabs: error + no-trailing-spaces: error + one-var: ["error", { "initialized": "never" }] diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..7d2c7033 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,22 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: monthly + groups: + github-actions: + patterns: + - "*" + + - package-ecosystem: npm + directory: / + schedule: + interval: monthly + ignore: + - dependency-name: "*" + update-types: ["version-update:semver-major"] + groups: + npm-dependencies: + patterns: + - "*" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..004c35ed --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,264 @@ +name: ci + +on: +- pull_request +- push + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + name: + - Node.js 0.8 + - Node.js 0.10 + - Node.js 0.12 + - io.js 1.x + - io.js 2.x + - io.js 3.x + - Node.js 4.x + - Node.js 5.x + - Node.js 6.x + - Node.js 7.x + - Node.js 8.x + - Node.js 9.x + - Node.js 10.x + - Node.js 11.x + - Node.js 12.x + - Node.js 13.x + - Node.js 14.x + - Node.js 15.x + - Node.js 16.x + - Node.js 17.x + - Node.js 18.x + - Node.js 19.x + - Node.js 20.x + - Node.js 21.x + - Node.js 22.x + - Node.js 23.x + - Node.js 24.x + - Node.js 25.x + + include: + - name: Node.js 0.8 + node-version: "0.8" + npm-i: mocha@2.5.3 supertest@1.1.0 + npm-rm: nyc + + - name: Node.js 0.10 + node-version: "0.10" + npm-i: mocha@2.5.3 nyc@10.3.2 supertest@2.0.0 + + - name: Node.js 0.12 + node-version: "0.12" + npm-i: mocha@2.5.3 nyc@10.3.2 supertest@2.0.0 + + - name: io.js 1.x + node-version: "1.8" + npm-i: mocha@2.5.3 nyc@10.3.2 supertest@2.0.0 + + - name: io.js 2.x + node-version: "2.5" + npm-i: mocha@2.5.3 nyc@10.3.2 supertest@2.0.0 + + - name: io.js 3.x + node-version: "3.3" + npm-i: mocha@2.5.3 nyc@10.3.2 supertest@2.0.0 + + - name: Node.js 4.x + node-version: "4.9" + npm-i: mocha@5.2.0 nyc@11.9.0 supertest@3.4.2 + + - name: Node.js 5.x + node-version: "5.12" + npm-i: mocha@5.2.0 nyc@11.9.0 supertest@3.4.2 + + - name: Node.js 6.x + node-version: "6.17" + npm-i: mocha@6.2.2 nyc@14.1.1 supertest@6.1.6 + + - name: Node.js 7.x + node-version: "7.10" + npm-i: mocha@6.2.2 nyc@14.1.1 supertest@6.1.6 + + - name: Node.js 8.x + node-version: "8.17" + npm-i: mocha@7.2.0 nyc@14.1.1 supertest@6.1.6 + + - name: Node.js 9.x + node-version: "9.11" + npm-i: mocha@7.2.0 nyc@14.1.1 supertest@6.1.6 + + - name: Node.js 10.x + node-version: "10.24" + npm-i: mocha@8.4.0 supertest@6.1.6 + + - name: Node.js 11.x + node-version: "11.15" + npm-i: mocha@8.4.0 supertest@6.1.6 + + - name: Node.js 12.x + node-version: "12.22" + npm-i: mocha@9.2.2 supertest@6.1.6 + + - name: Node.js 13.x + node-version: "13.14" + npm-i: mocha@9.2.2 supertest@6.1.6 + + - name: Node.js 14.x + node-version: "14.21" + + - name: Node.js 15.x + node-version: "15.14" + npm-i: supertest@6.1.6 + + - name: Node.js 16.x + node-version: "16.20" + + - name: Node.js 17.x + node-version: "17.9" + + - name: Node.js 18.x + node-version: "18.19" + + - name: Node.js 19.x + node-version: "19.9" + + - name: Node.js 20.x + node-version: "20" + + - name: Node.js 21.x + node-version: "21" + + - name: Node.js 22.x + node-version: "22" + + - name: Node.js 23.x + node-version: "23" + + - name: Node.js 24.x + node-version: "24" + + - name: Node.js 25.x + node-version: "25" + + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Install Node.js ${{ matrix.node-version }} + shell: bash -eo pipefail -l {0} + run: | + nvm install --default ${{ matrix.node-version }} + if [[ "${{ matrix.node-version }}" == 0.* && "$(cut -d. -f2 <<< "${{ matrix.node-version }}")" -lt 10 ]]; then + nvm install --alias=npm 0.10 + nvm use ${{ matrix.node-version }} + if [[ "$(npm -v)" == 1.1.* ]]; then + nvm exec npm npm install -g npm@1.1 + ln -fs "$(which npm)" "$(dirname "$(nvm which npm)")/npm" + else + sed -i '1s;^.*$;'"$(printf '#!%q' "$(nvm which npm)")"';' "$(readlink -f "$(which npm)")" + fi + npm config set strict-ssl false + fi + dirname "$(nvm which ${{ matrix.node-version }})" >> "$GITHUB_PATH" + + - name: Configure npm + run: | + if [[ "$(npm config get package-lock)" == "true" ]]; then + npm config set package-lock false + else + npm config set shrinkwrap false + fi + + - name: Remove npm module(s) ${{ matrix.npm-rm }} + run: npm rm --silent --save-dev ${{ matrix.npm-rm }} + if: matrix.npm-rm != '' + + - name: Install npm module(s) ${{ matrix.npm-i }} + run: npm install --save-dev ${{ matrix.npm-i }} + if: matrix.npm-i != '' + + - name: Setup Node.js version-specific dependencies + shell: bash + run: | + # eslint for linting + # - remove on Node.js < 12 + if [[ "$(cut -d. -f1 <<< "${{ matrix.node-version }}")" -lt 12 ]]; then + node -pe 'Object.keys(require("./package").devDependencies).join("\n")' | \ + grep -E '^eslint(-|$)' | \ + sort -r | \ + xargs -n1 npm rm --silent --save-dev + fi + + - name: Install Node.js dependencies + run: npm install + + - name: List environment + id: list_env + shell: bash + run: | + echo "node@$(node -v)" + echo "npm@$(npm -v)" + npm -s ls ||: + (npm -s ls --depth=0 ||:) | awk -F'[ @]' 'NR>1 && $2 { print $2 "=" $3 }' >> "$GITHUB_OUTPUT" + + - name: Run tests + shell: bash + run: | + if npm -ps ls nyc | grep -q nyc; then + npm run test-ci + cp coverage/lcov.info "coverage/${{ matrix.node-version }}.lcov" + else + npm test + fi + + - name: Lint code + if: steps.list_env.outputs.eslint != '' + run: npm run lint + + - name: Collect code coverage + if: steps.list_env.outputs.nyc != '' + run: | + if [[ -d ./coverage ]]; then + mv ./coverage "./${{ matrix.node-version }}" + mkdir ./coverage + mv "./${{ matrix.node-version }}" "./coverage/${{ matrix.node-version }}" + fi + + - name: Upload code coverage + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + if: steps.list_env.outputs.nyc != '' + with: + name: coverage-${{ matrix.node-version }} + path: "./coverage/${{ matrix.node-version }}" + retention-days: 1 + + coverage: + permissions: + checks: write # for coverallsapp/github-action to create new checks + contents: read # for actions/checkout to fetch code + needs: test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Install lcov + shell: bash + run: sudo apt-get -y install lcov + + - name: Collect coverage reports + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + path: ./coverage + + - name: Merge coverage reports + shell: bash + run: find ./coverage -name lcov.info -exec printf '-a %q\n' {} \; | xargs lcov -o ./coverage/lcov.info + + - name: Upload coverage report + uses: coverallsapp/github-action@5cbfd81b66ca5d10c19b062c04de0199c215fb6e # master + with: + github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 00000000..139ec706 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,66 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL" + +on: + push: + branches: ["master"] + pull_request: + # The branches below must be a subset of the branches above + branches: ["master"] + schedule: + - cron: "0 0 * * 1" + +permissions: + contents: read + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + steps: + - name: Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3 + with: + languages: javascript + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + + # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). + # If this step fails, then you should remove it and run the build manually (see below) + # - name: Autobuild + # uses: github/codeql-action/autobuild@3ab4101902695724f9365a384f86c1074d94e18c # v3.24.7 + + # â„šī¸ Command-line programs to run using the OS shell. + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + + # If the Autobuild fails above, remove it and uncomment the following three lines. + # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. + + # - run: | + # echo "Run, Build Application using script" + # ./location_of_script_within_repo/buildscript.sh + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3 + with: + category: "/language:javascript" \ No newline at end of file diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml new file mode 100644 index 00000000..2bb15b23 --- /dev/null +++ b/.github/workflows/scorecard.yml @@ -0,0 +1,73 @@ +# This workflow uses actions that are not certified by GitHub. They are provided +# by a third-party and are governed by separate terms of service, privacy +# policy, and support documentation. + +name: Scorecard supply-chain security + +on: + # For Branch-Protection check. Only the default branch is supported. See + # https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection + branch_protection_rule: + # To guarantee Maintained check is occasionally updated. See + # https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained + schedule: + - cron: '16 21 * * 1' + push: + branches: [ "master" ] + +# Declare default permissions as read only. +permissions: read-all + +jobs: + analysis: + name: Scorecard analysis + runs-on: ubuntu-latest + permissions: + # Needed to upload the results to code-scanning dashboard. + security-events: write + # Needed to publish results and get a badge (see publish_results below). + id-token: write + # Uncomment the permissions below if installing in a private repository. + # contents: read + # actions: read + + steps: + - name: "Checkout code" + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: "Run analysis" + uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 + with: + results_file: results.sarif + results_format: sarif + # (Optional) "write" PAT token. Uncomment the `repo_token` line below if: + # - you want to enable the Branch-Protection check on a *public* repository, or + # - you are installing Scorecard on a *private* repository + # To create the PAT, follow the steps in https://github.com/ossf/scorecard-action#authentication-with-pat. + # repo_token: ${{ secrets.SCORECARD_TOKEN }} + + # Public repositories: + # - Publish results to OpenSSF REST API for easy access by consumers + # - Allows the repository to include the Scorecard badge. + # - See https://github.com/ossf/scorecard-action#publishing-results. + # For private repositories: + # - `publish_results` will always be set to `false`, regardless + # of the value entered here. + publish_results: true + + # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF + # format to the repository Actions tab. + - name: "Upload artifact" + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: SARIF file + path: results.sarif + retention-days: 5 + + # Upload the results to GitHub's code scanning dashboard. + - name: "Upload to code-scanning" + uses: github/codeql-action/upload-sarif@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3 + with: + sarif_file: results.sarif \ No newline at end of file diff --git a/.gitignore b/.gitignore index df9af16b..1b6fef23 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ +.nyc_output coverage node_modules npm-debug.log +package-lock.json +/test/fixtures/server.crt +/test/fixtures/server.key diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 1ff243c5..00000000 --- a/.travis.yml +++ /dev/null @@ -1,11 +0,0 @@ -language: node_js -node_js: - - "0.8" - - "0.10" - - "0.11" -matrix: - allow_failures: - - node_js: "0.11" - fast_finish: true -script: "npm run-script test-travis" -after_script: "npm install coveralls@2.10.0 && cat ./coverage/lcov.info | coveralls" diff --git a/HISTORY.md b/HISTORY.md index 4721c9c6..251d5fa7 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,3 +1,309 @@ +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 ================== diff --git a/LICENSE b/LICENSE index f24497a5..9b59ff85 100644 --- a/LICENSE +++ b/LICENSE @@ -2,7 +2,7 @@ Copyright (c) 2010 Sencha Inc. Copyright (c) 2011 TJ Holowaychuk -Copyright (c) 2014 Douglas Christopher Wilson +Copyright (c) 2014-2015 Douglas Christopher Wilson Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the diff --git a/README.md b/README.md index 5e889faa..162ea14d 100644 --- a/README.md +++ b/README.md @@ -1,79 +1,210 @@ # express-session -[![NPM Version][npm-image]][npm-url] -[![NPM Downloads][downloads-image]][downloads-url] -[![Build Status][travis-image]][travis-url] +[![NPM Version][npm-version-image]][npm-url] +[![NPM Downloads][npm-downloads-image]][node-url] +[![Build Status][ci-image]][ci-url] [![Test Coverage][coveralls-image]][coveralls-url] -[![Gratipay][gratipay-image]][gratipay-url] ## Installation -```bash +This is a [Node.js](https://nodejs.org/en/) module available through the +[npm registry](https://www.npmjs.com/). Installation is done using the +[`npm install` command](https://docs.npmjs.com/getting-started/installing-npm-packages-locally): + +```sh $ npm install express-session ``` ## API ```js -var express = require('express') var session = require('express-session') +``` -var app = express() +### session(options) -app.use(session({secret: 'keyboard cat'})) -``` +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. -### session(options) +**Note** Since version 1.5.0, the [`cookie-parser` middleware](https://www.npmjs.com/package/cookie-parser) +no longer needs to be used for this module to work. This module now directly reads +and writes cookies on `req`/`res`. Using `cookie-parser` may result in issues +if the `secret` is not the same between this module and `cookie-parser`. -Setup session store with the given `options`. +**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. -Session data is _not_ saved in the cookie itself, just the session ID. +For a list of stores, see [compatible session stores](#compatible-session-stores). #### Options - - `name` - cookie name (formerly known as `key`). (default: `'connect.sid'`) - - `store` - session store instance. - - `secret` - session cookie is signed with this secret to prevent tampering. - - `cookie` - session cookie settings. - - (default: `{ path: '/', httpOnly: true, secure: false, maxAge: null }`) - - `genid` - function to call to generate a new session ID. (default: uses `uid2` library) - - `rolling` - forces a cookie set on every response. This resets the expiration date. (default: `false`) - - `resave` - forces session to be saved even when unmodified. (default: `true`) - - `proxy` - trust the reverse proxy when setting secure cookies (via "x-forwarded-proto" header). When set to `true`, the "x-forwarded-proto" header will be used. When set to `false`, all headers are ignored. When left unset, will use the "trust proxy" setting from express. (default: `undefined`) - - `saveUninitialized` - forces a session that is "uninitialized" to be saved to the store. A session is uninitialized when it is new but not modified. This is useful for implementing login sessions, reducing server storage usage, or complying with laws that require permission before setting a cookie. (default: `true`) - - `unset` - controls result of unsetting `req.session` (through `delete`, setting to `null`, etc.). This can be "keep" to keep the session in the store but ignore modifications or "destroy" to destroy the stored session. (default: `'keep'`) +`express-session` accepts these properties in the options object. -#### options.genid +##### cookie -Generate a custom session ID for new sessions. 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. +Settings object for the session ID cookie. The default value is +`{ path: '/', httpOnly: true, secure: false, maxAge: null }`. -**NOTE** be careful you generate unique IDs so your sessions do not conflict. +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({ - genid: function(req) { - return genuuid(); // use UUIDs for session IDs - }, - secret: 'keyboard cat' + 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 + } + } })) ``` -#### Cookie options +The following are options that can be set in this object. + +##### cookie.domain + +Specifies the value for the `Domain` `Set-Cookie` attribute. By default, no domain +is set, and most clients will consider the cookie to apply to only the current +domain. + +##### cookie.expires + +Specifies the `Date` object to be the value for the `Expires` `Set-Cookie` attribute. +By default, no expiration is set, and most clients will consider this a +"non-persistent cookie" and will delete it on a condition like exiting a web browser +application. + +**Note** If both `expires` and `maxAge` are set in the options, then the last one +defined in the object is what is used. + +**Note** The `expires` option should not be set directly; instead only use the `maxAge` +option. + +##### cookie.httpOnly + +Specifies the `boolean` value for the `HttpOnly` `Set-Cookie` attribute. When truthy, +the `HttpOnly` attribute is set, otherwise it is not. By default, the `HttpOnly` +attribute is set. + +**Note** be careful when setting this to `true`, as compliant clients will not allow +client-side JavaScript to see the cookie in `document.cookie`. + +##### cookie.maxAge + +Specifies the `number` (in milliseconds) to use when calculating the `Expires` +`Set-Cookie` attribute. This is done by taking the current server time and adding +`maxAge` milliseconds to the value to calculate an `Expires` datetime. By default, +no maximum age is set. + +**Note** If both `expires` and `maxAge` are set in the options, then the last one +defined in the object is what is used. + +##### cookie.partitioned + +Specifies the `boolean` value for the [`Partitioned` `Set-Cookie`](rfc-cutler-httpbis-partitioned-cookies) +attribute. When truthy, the `Partitioned` attribute is set, otherwise it is not. +By default, the `Partitioned` attribute is not set. + +**Note** This is an attribute that has not yet been fully standardized, and may +change in the future. This also means many clients may ignore this attribute until +they understand it. + +More information about can be found in [the proposal](https://github.com/privacycg/CHIPS). + +##### cookie.path + +Specifies the value for the `Path` `Set-Cookie`. By default, this is set to `'/'`, which +is the root path of the domain. + +Since 1.19.1, path matching follows [RFC 6265 section 5.1.4][rfc-6265-5.1.4]. This means +the session middleware will only activate when the request path is an exact match or falls +under a segment boundary of the cookie path. For example, a cookie path of `/admin` will +match `/admin` and `/admin/users` but will **not** match `/administrator`. Prior versions +used a simple prefix check that did not enforce segment boundaries. + +##### cookie.priority + +Specifies the `string` to be the value for the [`Priority` `Set-Cookie` attribute][rfc-west-cookie-priority-00-4.1]. + + - `'low'` will set the `Priority` attribute to `Low`. + - `'medium'` will set the `Priority` attribute to `Medium`, the default priority when not set. + - `'high'` will set the `Priority` attribute to `High`. + +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. -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: +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: +For using secure cookies in production, but allowing for testing in development, +the following is an example of enabling this setup based on `NODE_ENV` in express: ```js var app = express() @@ -90,9 +221,158 @@ if (app.get('env') === 'production') { app.use(session(sess)) ``` -By default `cookie.maxAge` is `null`, meaning no "expires" parameter is set -so the cookie becomes a browser-session cookie. When the user closes the -browser the cookie (and session) will be removed. +The `cookie.secure` option can also be set to the special value `'auto'` to have +this setting automatically match the determined security of the connection. Be +careful when using this setting if the site is available both as HTTP and HTTPS, +as once the cookie is set on HTTPS, it will no longer be visible over HTTP. This +is useful when the Express `"trust proxy"` setting is properly setup to simplify +development vs production configuration. + +##### genid + +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. + +The default value is a function which uses the `uid-safe` library to generate IDs. + +**NOTE** be careful to generate unique IDs so your sessions do not conflict. + +```js +app.use(session({ + genid: function(req) { + return genuuid() // use UUIDs for session IDs + }, + secret: 'keyboard cat' +})) +``` + +##### 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 @@ -101,27 +381,29 @@ which is (generally) serialized as JSON by the store, so nested objects are typically fine. For example below is a user-specific view counter: ```js +// Use the session middleware app.use(session({ secret: 'keyboard cat', cookie: { maxAge: 60000 }})) -app.use(function(req, res, next) { - var sess = req.session - if (sess.views) { - sess.views++ +// Access the session as req.session +app.get('/', function(req, res, next) { + if (req.session.views) { + req.session.views++ res.setHeader('Content-Type', 'text/html') - res.write('

views: ' + sess.views + '

') - res.write('

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

') + res.write('

views: ' + req.session.views + '

') + res.write('

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

') res.end() } else { - sess.views = 1 + req.session.views = 1 res.end('welcome to the session demo. refresh!') } }) ``` -#### Session.regenerate() +#### Session.regenerate(callback) -To regenerate the session simply invoke the method, once complete -a new SID and `Session` instance will be initialized at `req.session`. +To regenerate the session simply invoke the method. Once complete, +a new SID and `Session` instance will be initialized at `req.session` +and the `callback` will be invoked. ```js req.session.regenerate(function(err) { @@ -129,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) { @@ -139,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) { @@ -149,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) { @@ -162,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 @@ -184,39 +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 - - `.length(callback)` - - `.clear(callback)` +Every session store _must_ be an `EventEmitter` and implement specific +methods. The following methods are the list of **required**, **recommended**, +and **optional**. + + * Required methods are ones that this module will always call on the store. + * Recommended methods are ones that this module will call on the store if + available. + * Optional methods are ones this module does not call at all, but helps + present uniform stores to users. For an example implementation view the [connect-redis](http://github.com/visionmedia/connect-redis) repo. +### store.all(callback) + +**Optional** + +This optional method is used to get all sessions in the store as an array. The +`callback` should be called as `callback(error, sessions)`. + +### store.destroy(sid, callback) + +**Required** + +This required method is used to destroy/delete a session from the store given +a session ID (`sid`). The `callback` should be called as `callback(error)` once +the session is destroyed. + +### store.clear(callback) + +**Optional** + +This optional method is used to delete all sessions from the store. The +`callback` should be called as `callback(error)` once the store is cleared. + +### store.length(callback) + +**Optional** + +This optional method is used to get the count of all sessions in the store. +The `callback` should be called as `callback(error, len)`. + +### store.get(sid, callback) + +**Required** + +This required method is used to get a session from the store given a session +ID (`sid`). The `callback` should be called as `callback(error, session)`. + +The `session` argument should be a session if found, otherwise `null` or +`undefined` if the session was not found (and there was no error). A special +case is made when `error.code === 'ENOENT'` to act like `callback(null, null)`. + +### store.set(sid, session, callback) + +**Required** + +This required method is used to upsert a session into the store given a +session ID (`sid`) and session (`session`) object. The callback should be +called as `callback(error)` once the session has been set in the store. + +### store.touch(sid, session, callback) + +**Recommended** + +This recommended method is used to "touch" a given session given a +session ID (`sid`) and session (`session`) object. The `callback` should be +called as `callback(error)` once the session has been touched. + +This is primarily used when the store will automatically delete idle sessions +and this method is used to signal to the store the given session is active, +potentially resetting the idle timer. + +## Compatible Session Stores + +The following modules implement a session store that is compatible with this +module. Please make a PR to add additional modules :) + +[![★][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) -[npm-image]: https://img.shields.io/npm/v/express-session.svg?style=flat -[npm-url]: https://npmjs.org/package/express-session -[travis-image]: https://img.shields.io/travis/expressjs/session.svg?style=flat -[travis-url]: https://travis-ci.org/expressjs/session -[coveralls-image]: https://img.shields.io/coveralls/expressjs/session.svg?style=flat +[rfc-6265-5.1.4]: https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.4 +[rfc-6265bis-03-4.1.2.7]: https://tools.ietf.org/html/draft-ietf-httpbis-rfc6265bis-03#section-4.1.2.7 +[rfc-cutler-httpbis-partitioned-cookies]: https://tools.ietf.org/html/draft-cutler-httpbis-partitioned-cookies/ +[rfc-west-cookie-priority-00-4.1]: https://tools.ietf.org/html/draft-west-cookie-priority-00#section-4.1 +[ci-image]: https://badgen.net/github/checks/expressjs/session/master?label=ci +[ci-url]: https://github.com/expressjs/session/actions?query=workflow%3Aci +[coveralls-image]: https://badgen.net/coveralls/c/github/expressjs/session/master [coveralls-url]: https://coveralls.io/r/expressjs/session?branch=master -[downloads-image]: https://img.shields.io/npm/dm/express-session.svg?style=flat -[downloads-url]: https://npmjs.org/package/express-session -[gratipay-image]: https://img.shields.io/gratipay/dougwilson.svg?style=flat -[gratipay-url]: https://gratipay.com/dougwilson/ +[node-url]: https://nodejs.org/en/download +[npm-downloads-image]: https://badgen.net/npm/dm/express-session +[npm-url]: https://npmjs.org/package/express-session +[npm-version-image]: https://badgen.net/npm/v/express-session diff --git a/index.js b/index.js index 156b97dd..3e23e901 100644 --- a/index.js +++ b/index.js @@ -2,27 +2,31 @@ * express-session * Copyright(c) 2010 Sencha Inc. * Copyright(c) 2011 TJ Holowaychuk - * Copyright(c) 2014 Douglas Christopher Wilson + * Copyright(c) 2014-2015 Douglas Christopher Wilson * MIT Licensed */ +'use strict'; + /** * Module dependencies. + * @private */ +var Buffer = require('safe-buffer').Buffer var cookie = require('cookie'); -var crc = require('crc').crc32; +var crypto = require('crypto') var debug = require('debug')('express-session'); var deprecate = require('depd')('express-session'); +var onHeaders = require('on-headers') var parseUrl = require('parseurl'); +var signature = require('cookie-signature') var uid = require('uid-safe').sync - , onHeaders = require('on-headers') - , signature = require('cookie-signature') +var Cookie = require('./session/cookie') +var MemoryStore = require('./session/memory') var Session = require('./session/session') - , MemoryStore = require('./session/memory') - , Cookie = require('./session/cookie') - , Store = require('./session/store') +var Store = require('./session/store') // environment @@ -45,6 +49,7 @@ exports.MemoryStore = MemoryStore; /** * Warning message for `MemoryStore` usage in production. + * @private */ var warning = 'Warning: connect.session() MemoryStore is not\n' @@ -53,6 +58,7 @@ var warning = 'Warning: connect.session() MemoryStore is not\n' /** * Node.js 0.8+ async implementation. + * @private */ /* istanbul ignore next */ @@ -63,30 +69,50 @@ var defer = typeof setImmediate === 'function' /** * 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 - , storeReady = true - , rollingSessions = options.rolling || false; - var resaveSession = options.resave; - var saveUninitializedSession = options.saveUninitialized; +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 - var generateId = options.genid || generateSessionId; + // 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'); @@ -102,16 +128,29 @@ function session(options){ saveUninitializedSession = true; } - if (options.unset && options.unset !== 'destroy' && options.unset !== 'keep') { + if (opts.unset && opts.unset !== 'destroy' && opts.unset !== 'keep') { throw new TypeError('unset option must be "destroy" or "keep"'); } // TODO: switch to "destroy" on next major - var unsetDestroy = options.unset === 'destroy'; + var unsetDestroy = opts.unset === 'destroy' + + if (Array.isArray(secret) && secret.length === 0) { + throw new TypeError('secret option array must contain one or more strings'); + } + + 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); } @@ -119,44 +158,78 @@ function session(options){ store.generate = function(req){ req.sessionID = generateId(req); req.session = new Session(req); - req.session.cookie = new Cookie(cookie); + var resolvedCookieOptions = typeof cookieOptions === 'function' ? cookieOptions(req) : cookieOptions; + req.session.cookie = new Cookie(resolvedCookieOptions); + + var isSecure = issecure(req, trustProxy); + + if (resolvedCookieOptions.secure === 'auto') { + req.session.cookie.secure = isSecure; + } + + if (resolvedCookieOptions.sameSite === 'auto') { + req.session.cookie.sameSite = isSecure ? 'none' : 'lax'; + } }; - store.on('disconnect', function(){ storeReady = false; }); - store.on('connect', function(){ storeReady = true; }); + var storeImplementsTouch = typeof store.touch === 'function'; - if (!options.secret) { - deprecate('req.secret; provide secret option'); - } + // register event listeners for the store to track readiness + var storeReady = true + store.on('disconnect', function ondisconnect() { + storeReady = false + }) + store.on('connect', function onconnect() { + storeReady = true + }) return function session(req, res, next) { // self-awareness - if (req.session) return next(); + if (req.session) { + next() + return + } // Handle connection as if there is no session if // the store has temporarily disconnected etc - if (!storeReady) return debug('store is disconnected'), next(); + if (!storeReady) { + debug('store is disconnected') + next() + return + } // pathname mismatch - var originalPath = parseUrl.original(req).pathname; - if (0 != originalPath.indexOf(cookie.path || '/')) return next(); + var originalPath = parseUrl.original(req).pathname || '/' + var resolvedCookieOptions = typeof cookieOptions === 'function' ? cookieOptions(req) : cookieOptions + var cfgPath = resolvedCookieOptions.path || '/' + + if (!rfcPathMatch(originalPath, cfgPath)) { + debug('pathname mismatch') + next() + return + } + + + // ensure a secret is available or bail + if (!secret && !req.secret) { + 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; - - // ensure secret is available or bail - if (!secret) next(new Error('`secret` option required for sessions')); + var secrets = secret || [req.secret]; var originalHash; var originalId; var savedHash; + var touched = false // expose store req.sessionStore = store; // get the session ID from the cookie - var cookieId = req.sessionID = getcookie(req, name, secret); + var cookieId = req.sessionID = getcookie(req, name, secrets); // set-cookie onHeaders(res, function(){ @@ -165,19 +238,28 @@ function session(options){ return; } - var cookie = req.session.cookie; + if (!shouldSetCookie(req)) { + return; + } // only send secure cookies via https - if (cookie.secure && !issecure(req, trustProxy)) { + if (req.session.cookie.secure && !issecure(req, trustProxy)) { debug('not secured'); return; } - if (!shouldSetCookie(req)) { - return; + if (!touched) { + // touch session + req.session.touch() + touched = true } - setcookie(res, name, req.sessionID, secret, cookie.data); + // set cookie + try { + setcookie(res, name, req.sessionID, secrets[0], req.session.cookie.data) + } catch (err) { + defer(next, err) + } }); // proxy end() to commit the session @@ -194,10 +276,6 @@ function session(options){ var ret; var sync = true; - if (chunk == null) { - chunk = ''; - } - function writeend() { if (sync) { ret = _end.call(res, chunk, encoding); @@ -213,12 +291,21 @@ function session(options){ return ret; } + if (!res._header) { + res._implicitHeader() + } + + 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) - ? new Buffer(chunk, encoding) + ? Buffer.from(chunk, encoding) : chunk; encoding = undefined; @@ -257,8 +344,11 @@ function session(options){ return _end.call(res, chunk, encoding); } - // touch session - req.session.touch(); + if (!touched) { + // touch session + req.session.touch() + touched = true + } if (shouldSave(req)) { req.session.save(function onsave(err) { @@ -269,6 +359,19 @@ function session(options){ 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(); } @@ -283,16 +386,52 @@ function session(options){ wrapmethods(req.session); } + // inflate the session + function inflate (req, sess) { + store.createSession(req, sess) + originalId = req.sessionID + originalHash = hash(sess) + + if (!resaveSession) { + savedHash = originalHash + } + + wrapmethods(req.session) + } + + function rewrapmethods (sess, callback) { + return function () { + if (req.session !== sess) { + wrapmethods(req.session) + } + + callback.apply(this, arguments) + } + } + // wrap session methods function wrapmethods(sess) { + var _reload = sess.reload var _save = sess.save; + function reload(callback) { + debug('reloading %s', this.id) + _reload.call(this, rewrapmethods(this, callback)) + } + function save() { debug('saving %s', this.id); savedHash = hash(this); _save.apply(this, arguments); } + Object.defineProperty(sess, 'reload', { + configurable: true, + enumerable: false, + value: reload, + writable: true + }) + Object.defineProperty(sess, 'save', { configurable: true, enumerable: false, @@ -318,21 +457,38 @@ function session(options){ // determine if session should be saved to store function shouldSave(req) { - return !saveUninitializedSession && cookieId !== req.sessionID + // 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) { - // in case of rolling session, always reset the cookie - if (rollingSessions) { - return true; + // cannot set cookie without a session ID + if (typeof req.sessionID !== 'string') { + return false; } - return cookieId != req.sessionID + return cookieId !== req.sessionID ? saveUninitializedSession || isModified(req.session) - : req.session.cookie.expires != null && isModified(req.session); + : rollingSessions || req.session.cookie.expires != null && isModified(req.session); } // generate a session if the browser doesn't send a sessionID @@ -347,46 +503,74 @@ function session(options){ debug('fetching %s', req.sessionID); store.get(req.sessionID, function(err, sess){ // error handling - if (err) { + if (err && err.code !== 'ENOENT') { debug('error %j', err); + next(err) + return + } - if (err.code !== 'ENOENT') { - next(err); - return; - } - - generate(); - // no session - } else if (!sess) { - debug('no session found'); - generate(); - // populate req.session - } else { - debug('session found'); - store.createSession(req, sess); - originalId = req.sessionID; - originalHash = hash(sess); - - if (!resaveSession) { - savedHash = originalHash + try { + if (err || !sess) { + debug('no session found') + generate() + } else { + debug('session found') + inflate(req, sess) } - - wrapmethods(req.session); + } catch (e) { + next(e) + return } - next(); + next() }); }; }; +/** + * Check if the cookiePath matches the requestPath following the + * rules in RFC 6265 section 5.1.4. + * + * @param {String} requestPath + * @param {String} cookiePath + * @return {Boolean} + * @private + */ + +function rfcPathMatch(requestPath, cookiePath) { + // Normalize inputs (Node 0.8-safe) + requestPath = (typeof requestPath === 'string' && requestPath.length) ? requestPath : '/'; + cookiePath = (typeof cookiePath === 'string' && cookiePath.length) ? cookiePath : '/'; + + // Root cookie matches everything + if (cookiePath === '/') return true; + + // Exact match + if (requestPath === cookiePath) return true; + + // Prefix match + if (requestPath.indexOf(cookiePath) === 0) { + // If cookiePath ends with '/', any longer requestPath is OK + if (cookiePath.charAt(cookiePath.length - 1) === '/') return true; + + // Otherwise the next char after the prefix must be '/' + var nextChar = requestPath.length > cookiePath.length + ? requestPath.charAt(cookiePath.length) + : ''; + return nextChar === '/'; + } + + return false; +} + /** * Generate a session ID for a new session. * * @return {String} - * @api private + * @private */ -function generateSessionId(sess) { +function generateSessionId() { return uid(24); } @@ -394,10 +578,10 @@ function generateSessionId(sess) { * Get the session ID cookie from request. * * @return {string} - * @api private + * @private */ -function getcookie(req, name, secret) { +function getcookie(req, name, secrets) { var header = req.headers.cookie; var raw; var val; @@ -409,8 +593,8 @@ function getcookie(req, name, secret) { raw = cookies[name]; if (raw) { - if (raw.substr(0, 2) === 's:') { - val = signature.unsign(raw.slice(2), secret); + if (raw.slice(0, 2) === 's:') { + val = unsigncookie(raw.slice(2), secrets); if (val === false) { debug('cookie signature invalid'); @@ -436,8 +620,8 @@ function getcookie(req, name, secret) { raw = req.cookies[name]; if (raw) { - if (raw.substr(0, 2) === 's:') { - val = signature.unsign(raw.slice(2), secret); + if (raw.slice(0, 2) === 's:') { + val = unsigncookie(raw.slice(2), secrets); if (val) { deprecate('cookie should be available in req.headers.cookie'); @@ -461,15 +645,25 @@ function getcookie(req, name, secret) { * * @param {Object} sess * @return {String} - * @api private + * @private */ function hash(sess) { - return crc(JSON.stringify(sess, function (key, val) { - if (key !== 'cookie') { - return val; + // serialize + var str = JSON.stringify(sess, function (key, val) { + // ignore sess.cookie property + if (this === sess && key === 'cookie') { + return } - })); + + return val + }) + + // hash + return crypto + .createHash('sha1') + .update(str, 'utf8') + .digest('hex') } /** @@ -478,12 +672,13 @@ function hash(sess) { * @param {Object} req * @param {Boolean} [trustProxy] * @return {Boolean} - * @api private + * @private */ function issecure(req, trustProxy) { // socket is https server - if (req.connection && req.connection.encrypted) { + var socket = req.socket || req.connection; + if (socket && socket.encrypted) { return true; } @@ -494,32 +689,53 @@ function issecure(req, trustProxy) { // no explicit trust; try req.secure from express if (trustProxy !== true) { - var secure = req.secure; - return typeof secure === 'boolean' - ? secure - : false; + return req.secure === true } // read the proto from x-forwarded-proto header var header = req.headers['x-forwarded-proto'] || ''; var index = header.indexOf(','); var proto = index !== -1 - ? header.substr(0, index).toLowerCase().trim() + ? header.slice(0, index).toLowerCase().trim() : header.toLowerCase().trim() return proto === 'https'; } +/** + * 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) - : Array.isArray(data) ? [prev].concat(data) - : [prev, data]; + var prev = res.getHeader('Set-Cookie') || [] + var header = Array.isArray(prev) ? prev.concat(data) : [prev, data]; + + res.setHeader('Set-Cookie', header) +} + +/** + * 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; + } + } - res.setHeader('set-cookie', header) + return false; } diff --git a/package.json b/package.json index 7e9c4816..36387796 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "express-session", - "version": "1.8.2", + "version": "1.19.0", "description": "Simple session middleware for Express", "author": "TJ Holowaychuk (http://tjholowaychuk.com)", "contributors": [ @@ -8,39 +8,43 @@ "Joe Wagner " ], "repository": "expressjs/session", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + }, "license": "MIT", "dependencies": { - "cookie": "0.1.2", - "cookie-signature": "1.0.5", - "crc": "3.0.0", - "debug": "~2.0.0", - "depd": "0.4.5", - "on-headers": "~1.0.0", - "parseurl": "~1.3.0", - "uid-safe": "1.0.1", - "utils-merge": "1.0.0" + "cookie": "~0.7.2", + "cookie-signature": "~1.0.7", + "debug": "~2.6.9", + "depd": "~2.0.0", + "on-headers": "~1.1.0", + "parseurl": "~1.3.3", + "safe-buffer": "~5.2.1", + "uid-safe": "~2.1.5" }, "devDependencies": { - "after": "0.8.1", - "cookie-parser": "~1.3.3", - "istanbul": "0.3.2", - "express": "~4.8.8", - "mocha": "~1.21.4", - "should": "~4.0.4", - "supertest": "~0.14.0" + "after": "0.8.2", + "cookie-parser": "1.4.6", + "eslint": "8.56.0", + "eslint-plugin-markdown": "3.0.1", + "express": "4.17.3", + "mocha": "10.8.2", + "nyc": "15.1.0", + "supertest": "6.3.4" }, "files": [ "session/", - "HISTORY.md", - "LICENSE", "index.js" ], "engines": { "node": ">= 0.8.0" }, "scripts": { - "test": "mocha --bail --reporter spec test/", - "test-cov": "istanbul cover node_modules/mocha/bin/_mocha -- --reporter dot test/", - "test-travis": "istanbul cover node_modules/mocha/bin/_mocha --report lcovonly -- --reporter spec test/" + "lint": "eslint . && node ./scripts/lint-readme.js", + "test": "./test/support/gencert.sh && mocha --require test/support/env --check-leaks --no-exit --reporter spec test/", + "test-ci": "nyc --reporter=lcov --reporter=text npm test", + "test-cov": "nyc npm test", + "version": "node scripts/version-history.js && git add HISTORY.md" } } diff --git a/scripts/lint-readme.js b/scripts/lint-readme.js new file mode 100644 index 00000000..f4c50d76 --- /dev/null +++ b/scripts/lint-readme.js @@ -0,0 +1,93 @@ +var assert = require('assert') +var fs = require('fs') +var path = require('path') + +var BADGE_STAR_LINK_REGEXP = /^https:\/\/badgen.net\/github\/stars\/[^/]+\/[^/]+?label=%E2%98%85$/ +var MARKDOWN_LINK_REGEXP = /^\[([^ \]]+)\]: ([^ ]+)$/ +var MARKDOWN_SECTION_REGEXP = /^#+ (.+)$/ +var NEWLINE_REGEXP = /\r?\n/ +var README_PATH = path.join(__dirname, '..', 'README.md') +var README_CONTENTS = fs.readFileSync(README_PATH, 'utf-8') +var STORE_HEADER_REGEXP = /^\[!\[★\]\[([^ \]]+)\] ([^ \]]+)\]\[([^ \]]+)\](?: .+)?$/ + +var header = null +var lintedStores = false +var section = null +var state = 0 + +README_CONTENTS.split(NEWLINE_REGEXP).forEach(function (line, lineidx) { + section = (MARKDOWN_SECTION_REGEXP.exec(line) || [])[1] || section + + if (section === 'Compatible Session Stores') { + lintedStores = true + + switch (state) { + case 0: // premble + if (line[0] !== '[') break + state = 1 + case 1: // header + var prev = header + + if (!(header = STORE_HEADER_REGEXP.exec(line))) { + expect(lineidx, 'session store header', line) + } else if (prev && prev[2].replace(/^[^/]+\//, '').localeCompare(header[2].replace(/^[^/]+\//, '')) > 0) { + expect(lineidx, (header[2] + ' to be alphabetically before ' + prev[2]), line) + state = 2 + } else { + state = 2 + } + + break + case 2: // blank line + if (line && MARKDOWN_LINK_REGEXP.test(line)) { + expect(lineidx, 'blank line or wrapped description', line) + } else if (line === '') { + state = 3 + } + break + case 3: // url link + var urlLink = MARKDOWN_LINK_REGEXP.exec(line) + + if (!urlLink) { + expect(lineidx, 'link reference', line) + } else if (urlLink[1] !== header[3]) { + expect(lineidx, ('link name of ' + header[3]), line) + } else if (urlLink[2] !== ('https://www.npmjs.com/package/' + header[2])) { + expect(lineidx, ('link url of https://www.npmjs.com/package/' + header[2]), line) + } else { + state = 4 + } + + break + case 4: // image link + var imageLink = MARKDOWN_LINK_REGEXP.exec(line) + + if (!imageLink) { + expect(lineidx, 'link reference', line) + } else if (imageLink[1] !== header[1]) { + expect(lineidx, ('link name of ' + header[1]), line) + } else if (!BADGE_STAR_LINK_REGEXP.test(imageLink[2])) { + expect(lineidx, ('link url to github stars badge'), line) + } else { + state = 5 + } + + break + case 5: // blank line + if (line !== '') { + expect(lineidx, 'blank line after links', line) + } else { + state = 1 + } + break + } + } +}) + +assert.ok(lintedStores, 'Compatible Session Stores section linted') + +function expect (lineidx, message, line) { + console.log('Expected %s on line %d', message, (lineidx + 1)) + console.log(' Got: %s', line) + process.exitCode = 1 +} diff --git a/scripts/version-history.js b/scripts/version-history.js new file mode 100644 index 00000000..c58268ad --- /dev/null +++ b/scripts/version-history.js @@ -0,0 +1,63 @@ +'use strict' + +var fs = require('fs') +var path = require('path') + +var HISTORY_FILE_PATH = path.join(__dirname, '..', 'HISTORY.md') +var MD_HEADER_REGEXP = /^====*$/ +var VERSION = process.env.npm_package_version +var VERSION_PLACEHOLDER_REGEXP = /^(?:unreleased|(\d+\.)+x)$/ + +var historyFileLines = fs.readFileSync(HISTORY_FILE_PATH, 'utf-8').split('\n') + +if (!MD_HEADER_REGEXP.test(historyFileLines[1])) { + console.error('Missing header in HISTORY.md') + process.exit(1) +} + +if (!VERSION_PLACEHOLDER_REGEXP.test(historyFileLines[0])) { + console.error('Missing placeholder version in HISTORY.md') + process.exit(1) +} + +if (historyFileLines[0].indexOf('x') !== -1) { + var versionCheckRegExp = new RegExp('^' + historyFileLines[0].replace('x', '.+') + '$') + + if (!versionCheckRegExp.test(VERSION)) { + console.error('Version %s does not match placeholder %s', VERSION, historyFileLines[0]) + process.exit(1) + } +} + +historyFileLines[0] = VERSION + ' / ' + getLocaleDate() +historyFileLines[1] = repeat('=', historyFileLines[0].length) + +fs.writeFileSync(HISTORY_FILE_PATH, historyFileLines.join('\n')) + +function getLocaleDate () { + var now = new Date() + + return zeroPad(now.getFullYear(), 4) + '-' + + zeroPad(now.getMonth() + 1, 2) + '-' + + zeroPad(now.getDate(), 2) +} + +function repeat (str, length) { + var out = '' + + for (var i = 0; i < length; i++) { + out += str + } + + return out +} + +function zeroPad (number, length) { + var num = number.toString() + + while (num.length < length) { + num = '0' + num + } + + return num +} diff --git a/session/cookie.js b/session/cookie.js index 86591de3..8bb5907b 100644 --- a/session/cookie.js +++ b/session/cookie.js @@ -1,4 +1,3 @@ - /*! * Connect - session - Cookie * Copyright(c) 2010 Sencha Inc. @@ -6,12 +5,14 @@ * MIT Licensed */ +'use strict'; + /** * Module dependencies. */ -var merge = require('utils-merge') - , cookie = require('cookie'); +var cookie = require('cookie') +var deprecate = require('depd')('express-session') /** * Initialize a new `Cookie` with the given `options`. @@ -25,10 +26,22 @@ var Cookie = module.exports = function Cookie(options) { this.path = '/'; this.maxAge = null; this.httpOnly = true; - if (options) merge(this, options); - this.originalMaxAge = undefined == this.originalMaxAge - ? this.maxAge - : this.originalMaxAge; + + if (options) { + if (typeof options !== 'object') { + throw new TypeError('argument options must be a object') + } + + for (var key in options) { + if (key !== 'data') { + this[key] = options[key] + } + } + } + + if (this.originalMaxAge === undefined || this.originalMaxAge === null) { + this.originalMaxAge = this.maxAge + } }; /*! @@ -68,7 +81,15 @@ Cookie.prototype = { */ set maxAge(ms) { - this.expires = 'number' == typeof ms + if (ms && typeof ms !== 'number' && !(ms instanceof Date)) { + throw new TypeError('maxAge must be a number or Date') + } + + if (ms instanceof Date) { + deprecate('maxAge as Date; pass number of milliseconds instead') + } + + this.expires = typeof ms === 'number' ? new Date(Date.now() + ms) : ms; }, @@ -95,12 +116,15 @@ Cookie.prototype = { get data() { return { - originalMaxAge: this.originalMaxAge + originalMaxAge: this.originalMaxAge, + partitioned: this.partitioned, + priority: this.priority , expires: this._expires , secure: this.secure , httpOnly: this.httpOnly , domain: this.domain , path: this.path + , sameSite: this.sameSite } }, diff --git a/session/memory.js b/session/memory.js index 4efe99da..11ed686c 100644 --- a/session/memory.js +++ b/session/memory.js @@ -1,19 +1,24 @@ - /*! - * 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 */ /* istanbul ignore next */ @@ -22,130 +27,161 @@ var defer = typeof setImmediate === 'function' : 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 = Object.create(null); -}; +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`. - * - * @param {String} sid - * @param {Function} fn - * @api public + * Inherit from Store. */ -MemoryStore.prototype.get = function(sid, fn){ - var self = this; - var sess = self.sessions[sid]; +util.inherits(MemoryStore, Store) - if (!sess) { - return defer(fn); - } +/** + * Get all active sessions. + * + * @param {function} callback + * @public + */ - // parse - sess = JSON.parse(sess); +MemoryStore.prototype.all = function all(callback) { + var sessionIds = Object.keys(this.sessions) + var sessions = Object.create(null) - var expires = typeof sess.cookie.expires === 'string' - ? new Date(sess.cookie.expires) - : sess.cookie.expires; + for (var i = 0; i < sessionIds.length; i++) { + var sessionId = sessionIds[i] + var session = getSession.call(this, sessionId) - // destroy expired session - if (expires && expires <= Date.now()) { - return self.destroy(sid, fn); + if (session) { + sessions[sessionId] = session; + } } - defer(fn, null, sess); -}; + 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){ - this.sessions[sid] = JSON.stringify(sess); - fn && defer(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){ - delete this.sessions[sid]; - fn && defer(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 keys = Object.keys(this.sessions); - var now = Date.now(); - var obj = Object.create(null); - var sess; - var sid; - - for (var i = 0, len = keys.length; i < len; ++i) { - sid = keys[i]; +MemoryStore.prototype.get = function get(sessionId, callback) { + defer(callback, null, getSession.call(this, sessionId)) +} - // parse - sess = JSON.parse(this.sessions[sid]); +/** + * Commit the given session associated with the given sessionId to the store. + * + * @param {string} sessionId + * @param {object} session + * @param {function} callback + * @public + */ - expires = typeof sess.cookie.expires === 'string' - ? new Date(sess.cookie.expires) - : sess.cookie.expires; +MemoryStore.prototype.set = function set(sessionId, session, callback) { + this.sessions[sessionId] = JSON.stringify(session) + callback && defer(callback) +} - if (!expires || expires > now) { - obj[sid] = sess; - } - } +/** + * Get number of active sessions. + * + * @param {function} callback + * @public + */ - fn && defer(fn, null, obj); -}; +MemoryStore.prototype.length = function length(callback) { + this.all(function (err, sessions) { + if (err) return callback(err) + callback(null, Object.keys(sessions).length) + }) +} /** - * Clear all 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.clear = function(fn){ - this.sessions = {}; - fn && defer(fn); -}; +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) +} /** - * Fetch number of sessions. - * - * @param {Function} fn - * @api public + * Get session from the store. + * @private */ -MemoryStore.prototype.length = function(fn){ - var len = Object.keys(this.sessions).length; - defer(fn, null, len); -}; +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 4647f9da..fee7608c 100644 --- a/session/session.js +++ b/session/session.js @@ -1,4 +1,3 @@ - /*! * Connect - session - Session * Copyright(c) 2010 Sencha Inc. @@ -6,6 +5,8 @@ * MIT Licensed */ +'use strict'; + /** * Expose Session. */ @@ -43,9 +44,9 @@ function Session(req, data) { * @api public */ -Session.prototype.touch = function(){ +defineMethod(Session.prototype, 'touch', function touch() { return this.resetMaxAge(); -}; +}); /** * Reset `.maxAge` to `.originalMaxAge`. @@ -54,10 +55,10 @@ Session.prototype.touch = function(){ * @api public */ -Session.prototype.resetMaxAge = function(){ +defineMethod(Session.prototype, 'resetMaxAge', function resetMaxAge() { this.cookie.maxAge = this.cookie.originalMaxAge; return this; -}; +}); /** * Save the session data with optional callback `fn(err)`. @@ -67,10 +68,10 @@ Session.prototype.resetMaxAge = function(){ * @api public */ -Session.prototype.save = function(fn){ +defineMethod(Session.prototype, 'save', function save(fn) { this.req.sessionStore.set(this.id, this, fn || function(){}); return this; -}; +}); /** * Re-loads the session data _without_ altering @@ -84,9 +85,10 @@ Session.prototype.save = function(fn){ * @api public */ -Session.prototype.reload = function(fn){ +defineMethod(Session.prototype, 'reload', function reload(fn) { var req = this.req - , store = this.req.sessionStore; + var store = this.req.sessionStore + store.get(this.id, function(err, sess){ if (err) return fn(err); if (!sess) return fn(new Error('failed to load session')); @@ -94,7 +96,7 @@ Session.prototype.reload = function(fn){ fn(); }); return this; -}; +}); /** * Destroy `this` session. @@ -104,11 +106,11 @@ Session.prototype.reload = function(fn){ * @api public */ -Session.prototype.destroy = function(fn){ +defineMethod(Session.prototype, 'destroy', function destroy(fn) { delete this.req.session; this.req.sessionStore.destroy(this.id, fn); return this; -}; +}); /** * Regenerate this request's session. @@ -118,7 +120,24 @@ Session.prototype.destroy = function(fn){ * @api public */ -Session.prototype.regenerate = function(fn){ +defineMethod(Session.prototype, 'regenerate', function regenerate(fn) { this.req.sessionStore.regenerate(this.req, fn); return this; +}); + +/** + * Helper function for creating a method on a prototype. + * + * @param {Object} obj + * @param {String} name + * @param {Function} fn + * @private + */ +function defineMethod(obj, name, fn) { + Object.defineProperty(obj, name, { + configurable: true, + enumerable: false, + value: fn, + writable: true + }); }; diff --git a/session/store.js b/session/store.js index 54294cbd..3793877e 100644 --- a/session/store.js +++ b/session/store.js @@ -1,4 +1,3 @@ - /*! * Connect - session - Store * Copyright(c) 2010 Sencha Inc. @@ -6,27 +5,39 @@ * MIT Licensed */ +'use strict'; + /** * Module dependencies. + * @private */ +var Cookie = require('./cookie') var EventEmitter = require('events').EventEmitter - , Session = require('./session') - , Cookie = require('./cookie'); +var Session = require('./session') +var util = require('util') /** - * Initialize abstract `Store`. - * - * @api private + * Module exports. + * @public + */ + +module.exports = Store + +/** + * Abstract base class for session stores. + * @public */ -var Store = module.exports = function Store(options){}; +function Store () { + EventEmitter.call(this) +} /** - * Inherit from `EventEmitter.prototype`. + * Inherit from EventEmitter. */ -Store.prototype.__proto__ = EventEmitter.prototype; +util.inherits(Store, EventEmitter) /** * Re-generate the given requests's session. @@ -59,8 +70,7 @@ Store.prototype.load = function(sid, fn){ if (err) return fn(err); if (!sess) return fn(); var req = { sessionID: sid, sessionStore: self }; - sess = self.createSession(req, sess); - fn(null, sess); + fn(null, self.createSession(req, sess)) }); }; @@ -75,10 +85,18 @@ Store.prototype.load = function(sid, fn){ Store.prototype.createSession = function(req, sess){ var expires = sess.cookie.expires - , orig = sess.cookie.originalMaxAge; + var originalMaxAge = sess.cookie.originalMaxAge + sess.cookie = new Cookie(sess.cookie); - if ('string' == typeof expires) sess.cookie.expires = new Date(expires); - sess.cookie.originalMaxAge = orig; + + if (typeof expires === 'string') { + // convert expires to a Date object + sess.cookie.expires = new Date(expires) + } + + // keep originalMaxAge intact + sess.cookie.originalMaxAge = originalMaxAge + req.session = new Session(req, sess); return req.session; }; diff --git a/test/cookie.js b/test/cookie.js new file mode 100644 index 00000000..ea676e35 --- /dev/null +++ b/test/cookie.js @@ -0,0 +1,134 @@ + +var assert = require('assert') +var Cookie = require('../session/cookie') + +describe('new Cookie()', function () { + it('should create a new cookie object', function () { + assert.strictEqual(typeof new Cookie(), 'object') + }) + + it('should default expires to null', function () { + var cookie = new Cookie() + assert.strictEqual(cookie.expires, null) + }) + + it('should default httpOnly to true', function () { + var cookie = new Cookie() + assert.strictEqual(cookie.httpOnly, true) + }) + + it('should default path to "/"', function () { + var cookie = new Cookie() + assert.strictEqual(cookie.path, '/') + }) + + it('should default maxAge to null', function () { + var cookie = new Cookie() + assert.strictEqual(cookie.maxAge, null) + }) + + describe('with options', function () { + it('should create a new cookie object', function () { + assert.strictEqual(typeof new Cookie({}), 'object') + }) + + it('should reject non-objects', function () { + assert.throws(function () { new Cookie(42) }, /argument options/) + assert.throws(function () { new Cookie('foo') }, /argument options/) + assert.throws(function () { new Cookie(true) }, /argument options/) + assert.throws(function () { new Cookie(function () {}) }, /argument options/) + }) + + it('should ignore "data" option', function () { + var cookie = new Cookie({ data: { foo: 'bar' }, path: '/foo' }) + + assert.strictEqual(typeof cookie, 'object') + assert.strictEqual(typeof cookie.data, 'object') + assert.strictEqual(cookie.data.path, '/foo') + assert.notStrictEqual(cookie.data.foo, 'bar') + }) + + describe('expires', function () { + it('should set expires', function () { + var expires = new Date(Date.now() + 60000) + var cookie = new Cookie({ expires: expires }) + + assert.strictEqual(cookie.expires, expires) + }) + + it('should set maxAge', function () { + var expires = new Date(Date.now() + 60000) + var cookie = new Cookie({ expires: expires }) + + assert.ok(expires.getTime() - Date.now() - 1000 <= cookie.maxAge) + assert.ok(expires.getTime() - Date.now() + 1000 >= cookie.maxAge) + }) + }) + + describe('httpOnly', function () { + it('should set httpOnly', function () { + var cookie = new Cookie({ httpOnly: false }) + + assert.strictEqual(cookie.httpOnly, false) + }) + }) + + describe('maxAge', function () { + it('should set expires', function () { + var maxAge = 60000 + var cookie = new Cookie({ maxAge: maxAge }) + + assert.ok(cookie.expires.getTime() - Date.now() - 1000 <= maxAge) + assert.ok(cookie.expires.getTime() - Date.now() + 1000 >= maxAge) + }) + + it('should set maxAge', function () { + var maxAge = 60000 + var cookie = new Cookie({ maxAge: maxAge }) + + assert.strictEqual(typeof cookie.maxAge, 'number') + assert.ok(cookie.maxAge - 1000 <= maxAge) + assert.ok(cookie.maxAge + 1000 >= maxAge) + }) + + it('should accept Date object', function () { + var maxAge = new Date(Date.now() + 60000) + var cookie = new Cookie({ maxAge: maxAge }) + + assert.strictEqual(cookie.expires.getTime(), maxAge.getTime()) + assert.ok(maxAge.getTime() - Date.now() - 1000 <= cookie.maxAge) + assert.ok(maxAge.getTime() - Date.now() + 1000 >= cookie.maxAge) + }) + + it('should reject invalid types', function() { + assert.throws(function() { new Cookie({ maxAge: '42' }) }, /maxAge/) + assert.throws(function() { new Cookie({ maxAge: true }) }, /maxAge/) + assert.throws(function() { new Cookie({ maxAge: function () {} }) }, /maxAge/) + }) + }) + + describe('partitioned', function () { + it('should set partitioned', function () { + var cookie = new Cookie({ partitioned: true }) + + assert.strictEqual(cookie.partitioned, true) + }) + }) + + describe('path', function () { + it('should set path', function () { + var cookie = new Cookie({ path: '/foo' }) + + assert.strictEqual(cookie.path, '/foo') + }) + }) + + describe('priority', function () { + it('should set priority', function () { + var cookie = new Cookie({ priority: 'high' }) + + assert.strictEqual(cookie.priority, 'high') + }) + }) + }) +}) diff --git a/test/fixtures/.gitkeep b/test/fixtures/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/test/session.js b/test/session.js index 82383f5c..405e3ddf 100644 --- a/test/session.js +++ b/test/session.js @@ -1,38 +1,38 @@ -process.env.NO_DEPRECATION = 'express-session'; - var after = require('after') +var assert = require('assert') +var cookieParser = require('cookie-parser') +var crypto = require('crypto') var express = require('express') - , assert = require('assert') - , request = require('supertest') - , should = require('should') - , cookieParser = require('cookie-parser') - , session = require('../') - , Cookie = require('../session/cookie') -var http = require('http'); +var fs = require('fs') +var http = require('http') +var https = require('https') +var request = require('supertest') +var session = require('../') +var SmartStore = require('./support/smart-store') +var SyncStore = require('./support/sync-store') +var utils = require('./support/utils') + +var Cookie = require('../session/cookie') var min = 60 * 1000; describe('session()', function(){ it('should export constructors', function(){ - session.Session.should.be.a.Function; - session.Store.should.be.a.Function; - session.MemoryStore.should.be.a.Function; + assert.strictEqual(typeof session.Session, 'function') + assert.strictEqual(typeof session.Store, 'function') + assert.strictEqual(typeof session.MemoryStore, 'function') }) it('should do nothing if req.session exists', function(done){ - var app = express() - .use(function(req, res, next){ req.session = {}; next(); }) - .use(session({ secret: 'keyboard cat', cookie: { maxAge: min }})) - .use(end); + function setup (req) { + req.session = {} + } - request(app) - .get('/') - .expect(200, function(err, res){ - if (err) return done(err); - should(cookie(res)).be.empty; - done(); - }); + request(createServer(setup)) + .get('/') + .expect(shouldNotHaveHeader('Set-Cookie')) + .expect(200, done) }) it('should error without secret', function(done){ @@ -42,15 +42,13 @@ describe('session()', function(){ }) it('should get secret from req.secret', function(done){ - var app = express() - .use(function(req, res, next){ req.secret = 'keyboard cat'; next(); }) - .use(session({ cookie: { maxAge: min }})) - .use(end); - app.set('env', 'test'); + function setup (req) { + req.secret = 'keyboard cat' + } - request(app) - .get('/') - .expect(200, '', done); + request(createServer(setup, { secret: undefined })) + .get('/') + .expect(200, '', done) }) it('should create a new session', function (done) { @@ -62,12 +60,12 @@ describe('session()', function(){ request(server) .get('/') + .expect(shouldSetCookie('connect.sid')) .expect(200, 'session active', function (err, res) { if (err) return done(err) - should(sid(res)).not.be.empty store.length(function (err, len) { if (err) return done(err) - len.should.equal(1) + assert.strictEqual(len, 1) done() }) }) @@ -82,9 +80,9 @@ describe('session()', function(){ request(server) .get('/') + .expect(shouldSetCookie('connect.sid')) .expect(200, 'session 1', function (err, res) { if (err) return done(err) - should(sid(res)).not.be.empty request(server) .get('/') .set('Cookie', cookie(res)) @@ -104,9 +102,9 @@ describe('session()', function(){ request(server) .get('/') + .expect(shouldSetCookie('connect.sid')) .expect(200, 'hello, world', function (err, res) { if (err) return done(err) - should(sid(res)).not.be.empty request(server) .get('/') .set('Cookie', cookie(res)) @@ -130,9 +128,9 @@ describe('session()', function(){ request(server) .get('/') + .expect(shouldSetCookie('connect.sid')) .expect(200, 'session 1', function (err, res) { if (err) return done(err) - should(sid(res)).not.be.empty request(server) .get('/') .set('Cookie', cookie(res)) @@ -154,7 +152,7 @@ describe('session()', function(){ if (err) return done(err) store.all(function (err, sess) { if (err) return done(err) - Object.keys(sess).should.have.length(2) + assert.strictEqual(Object.keys(sess).length, 2) done() }) } @@ -168,17 +166,25 @@ describe('session()', function(){ .expect(200, 'session created', cb) }) + it('should handle empty req.url', function (done) { + function setup (req) { + req.url = '' + } + + request(createServer(setup)) + .get('/') + .expect(shouldSetCookie('connect.sid')) + .expect(200, done) + }) + it('should handle multiple res.end calls', function(done){ - var app = express() - .use(session({ secret: 'keyboard cat', cookie: { maxAge: min }})) - .use(function(req, res){ - res.setHeader('Content-Type', 'text/plain'); - res.end('Hello, world!'); - res.end(); - }); - app.set('env', 'test'); + var server = createServer(null, function (req, res) { + res.setHeader('Content-Type', 'text/plain') + res.end('Hello, world!') + res.end() + }) - request(app) + request(server) .get('/') .expect('Content-Type', 'text/plain') .expect(200, 'Hello, world!', done); @@ -235,36 +241,71 @@ describe('session()', function(){ .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 saved = false var store = new session.MemoryStore() var server = createServer({ store: store }, function (req, res) { req.session.hit = true res.end('session saved') }) - var _set = store.set - store.set = function set(sid, sess, callback) { - setTimeout(function () { - _set.call(store, sid, sess, function (err) { - saved = true - callback(err) - }) - }, 200) - } - request(server) - .get('/') - .expect(200, 'session saved', function (err) { - if (err) return done(err) - saved.should.be.true - done() - }) + .get('/') + .expect(200) + .expect(shouldSetSessionInStore(store, 200)) + .expect('session saved') + .end(done) }) it('should have saved session even with empty response', function (done) { - var saved = false var store = new session.MemoryStore() var server = createServer({ store: store }, function (req, res) { req.session.hit = true @@ -272,27 +313,14 @@ describe('session()', function(){ res.end() }) - var _set = store.set - store.set = function set(sid, sess, callback) { - setTimeout(function () { - _set.call(store, sid, sess, function (err) { - saved = true - callback(err) - }) - }, 200) - } - request(server) - .get('/') - .expect(200, '', function (err) { - if (err) return done(err) - saved.should.be.true - done() - }) + .get('/') + .expect(200) + .expect(shouldSetSessionInStore(store, 200)) + .end(done) }) it('should have saved session even with multi-write', function (done) { - var saved = false var store = new session.MemoryStore() var server = createServer({ store: store }, function (req, res) { req.session.hit = true @@ -301,27 +329,15 @@ describe('session()', function(){ res.end('world') }) - var _set = store.set - store.set = function set(sid, sess, callback) { - setTimeout(function () { - _set.call(store, sid, sess, function (err) { - saved = true - callback(err) - }) - }, 200) - } - request(server) - .get('/') - .expect(200, 'hello, world', function (err) { - if (err) return done(err) - saved.should.be.true - done() - }) + .get('/') + .expect(200) + .expect(shouldSetSessionInStore(store, 200)) + .expect('hello, world') + .end(done) }) it('should have saved session even with non-chunked response', function (done) { - var saved = false var store = new session.MemoryStore() var server = createServer({ store: store }, function (req, res) { req.session.hit = true @@ -329,22 +345,48 @@ describe('session()', function(){ res.end('session saved') }) - var _set = store.set - store.set = function set(sid, sess, callback) { - setTimeout(function () { - _set.call(store, sid, sess, function (err) { - saved = true - callback(err) - }) - }, 200) - } + request(server) + .get('/') + .expect(200) + .expect(shouldSetSessionInStore(store, 200)) + .expect('session saved') + .end(done) + }) + + it('should have saved session with updated cookie expiration', function (done) { + var store = new session.MemoryStore() + var server = createServer({ cookie: { maxAge: min }, store: store }, function (req, res) { + req.session.user = 'bob' + res.end(req.session.id) + }) request(server) .get('/') - .expect(200, 'session saved', function (err) { + .expect(shouldSetCookie('connect.sid')) + .expect(200, function (err, res) { if (err) return done(err) - saved.should.be.true - done() + var id = res.text + store.get(id, function (err, sess) { + if (err) return done(err) + assert.ok(sess, 'session saved to store') + var exp = new Date(sess.cookie.expires) + assert.strictEqual(exp.toUTCString(), expires(res)) + setTimeout(function () { + request(server) + .get('/') + .set('Cookie', cookie(res)) + .expect(200, function (err, res) { + if (err) return done(err) + store.get(id, function (err, sess) { + if (err) return done(err) + assert.strictEqual(res.text, id) + assert.ok(sess, 'session still in store') + assert.notStrictEqual(new Date(sess.cookie.expires).toUTCString(), exp.toUTCString(), 'session cookie expiration updated') + done() + }) + }) + }, (1000 - (Date.now() % 1000) + 200)) + }) }) }) }) @@ -360,9 +402,9 @@ describe('session()', function(){ request(server) .get('/') + .expect(shouldSetCookie('connect.sid')) .expect(200, 'session 1', function (err, res) { if (err) return done(err) - should(sid(res)).not.be.empty store.clear(function (err) { if (err) return done(err) request(server) @@ -383,21 +425,17 @@ describe('session()', function(){ request(server) .get('/') + .expect(shouldSetCookie('connect.sid')) .expect(200, 'session 1', function (err, res) { if (err) return done(err) - var val = sid(res) - should(val).not.be.empty store.clear(function (err) { if (err) return done(err) request(server) .get('/') .set('Cookie', cookie(res)) - .expect(200, 'session 2', function (err, res) { - if (err) return done(err) - should(sid(res)).not.be.empty - should(sid(res)).not.equal(val) - done() - }) + .expect(shouldSetCookie('connect.sid')) + .expect(shouldSetCookieToDifferentSessionId(sid(res))) + .expect(200, 'session 2', done) }) }) }) @@ -414,13 +452,16 @@ describe('session()', function(){ request(server) .get('/') + .expect(shouldSetCookie('sessid')) .expect(200, 'session created', function (err, res) { if (err) return done(err) var val = sid(res) - should(val).not.be.empty + assert.ok(val) request(server) .get('/') .set('Cookie', 'sessid=' + val) + .expect(shouldSetCookie('sessid')) + .expect(shouldSetCookieToDifferentSessionId(val)) .expect(200, 'session created', done) }) }) @@ -435,14 +476,16 @@ describe('session()', function(){ request(server) .get('/') + .expect(shouldSetCookie('sessid')) .expect(200, 'session created', function (err, res) { if (err) return done(err) var val = cookie(res).replace(/...\./, '.') - should(val).not.be.empty + assert.ok(val) request(server) .get('/') .set('Cookie', val) + .expect(shouldSetCookie('sessid')) .expect(200, 'session created', done) }) }) @@ -459,15 +502,16 @@ describe('session()', function(){ request(server) .get('/') + .expect(shouldSetCookie('connect.sid')) .expect(200, 'session 1', function (err, res) { if (err) return done(err) - should(sid(res)).not.be.empty setTimeout(function () { request(server) .get('/') .set('Cookie', cookie(res)) + .expect(shouldSetCookie('connect.sid')) .expect(200, 'session 2', done) - }, 10) + }, 20) }) }) @@ -481,20 +525,16 @@ describe('session()', function(){ request(server) .get('/') + .expect(shouldSetCookie('connect.sid')) .expect(200, 'session 1', function (err, res) { if (err) return done(err) - var val = sid(res) - should(val).not.be.empty setTimeout(function () { request(server) .get('/') .set('Cookie', cookie(res)) - .expect(200, 'session 2', function (err, res) { - if (err) return done(err) - should(sid(res)).not.be.empty - should(sid(res)).not.equal(val) - done() - }) + .expect(shouldSetCookie('connect.sid')) + .expect(shouldSetCookieToDifferentSessionId(sid(res))) + .expect(200, 'session 2', done) }, 15) }) }) @@ -509,14 +549,13 @@ describe('session()', function(){ request(server) .get('/') + .expect(shouldSetCookie('connect.sid')) .expect(200, 'session 1', function (err, res) { if (err) return done(err) - var val = sid(res) - should(val).not.be.empty setTimeout(function () { store.all(function (err, sess) { if (err) return done(err) - Object.keys(sess).should.have.length(0) + assert.strictEqual(Object.keys(sess).length, 0) done() }) }, 10) @@ -524,6 +563,31 @@ describe('session()', function(){ }) }) + describe('when session without cookie property in store', function () { + it('should pass error from inflate', function (done) { + var count = 0 + var store = new session.MemoryStore() + var server = createServer({ store: store }, function (req, res) { + req.session.num = req.session.num || ++count + res.end('session ' + req.session.num) + }) + + request(server) + .get('/') + .expect(shouldSetCookie('connect.sid')) + .expect(200, 'session 1', function (err, res) { + if (err) return done(err) + store.set(sid(res), { foo: 'bar' }, function (err) { + if (err) return done(err) + request(server) + .get('/') + .set('Cookie', cookie(res)) + .expect(500, /Cannot read prop/, done) + }) + }) + }) + }) + describe('proxy option', function(){ describe('when enabled', function(){ var server @@ -535,488 +599,1021 @@ describe('session()', function(){ request(server) .get('/') .set('X-Forwarded-Proto', 'https') - .expect(200, function(err, res){ - if (err) return done(err); - should(cookie(res)).not.be.empty; - done(); - }); + .expect(shouldSetCookie('connect.sid')) + .expect(200, done) }) it('should trust X-Forwarded-Proto when comma-separated list', function(done){ request(server) .get('/') .set('X-Forwarded-Proto', 'https,http') - .expect(200, function(err, res){ - if (err) return done(err); - should(cookie(res)).not.be.empty; - done(); - }); + .expect(shouldSetCookie('connect.sid')) + .expect(200, done) }) it('should work when no header', function(done){ request(server) .get('/') - .expect(200, function(err, res){ - if (err) return done(err); - should(cookie(res)).be.empty; - done(); - }); + .expect(shouldNotHaveHeader('Set-Cookie')) + .expect(200, done) }) }) describe('when disabled', function(){ - var server before(function () { - server = createServer({ proxy: false, cookie: { secure: true, maxAge: 5 }}) + function setup (req) { + req.secure = req.headers['x-secure'] + ? JSON.parse(req.headers['x-secure']) + : undefined + } + + function respond (req, res) { + res.end(String(req.secure)) + } + + this.server = createServer(setup, { proxy: false, cookie: { secure: true }}, respond) }) it('should not trust X-Forwarded-Proto', function(done){ - request(server) + request(this.server) .get('/') .set('X-Forwarded-Proto', 'https') - .expect(200, function(err, res){ - if (err) return done(err); - should(cookie(res)).be.empty; - done(); - }); + .expect(shouldNotHaveHeader('Set-Cookie')) + .expect(200, done) }) - it('should ignore req.secure from express', function(done){ - var app = express() - .use(session({ secret: 'keyboard cat', proxy: false, cookie: { secure: true, maxAge: min }})) - .use(function(req, res) { res.json(req.secure); }); - app.enable('trust proxy'); - - request(app) + it('should ignore req.secure', function (done) { + request(this.server) .get('/') .set('X-Forwarded-Proto', 'https') - .expect(200, 'true', function(err, res){ - if (err) return done(err); - should(cookie(res)).be.empty; - done(); - }); + .set('X-Secure', 'true') + .expect(shouldNotHaveHeader('Set-Cookie')) + .expect(200, 'true', done) }) }) describe('when unspecified', function(){ - var server before(function () { - server = createServer({ cookie: { secure: true, maxAge: 5 }}) + function setup (req) { + req.secure = req.headers['x-secure'] + ? JSON.parse(req.headers['x-secure']) + : undefined + } + + function respond (req, res) { + res.end(String(req.secure)) + } + + this.server = createServer(setup, { cookie: { secure: true }}, respond) }) it('should not trust X-Forwarded-Proto', function(done){ - request(server) + request(this.server) .get('/') .set('X-Forwarded-Proto', 'https') - .expect(200, function(err, res){ - if (err) return done(err); - should(cookie(res)).be.empty; - done(); - }); + .expect(shouldNotHaveHeader('Set-Cookie')) + .expect(200, done) }) - it('should use req.secure from express', function(done){ - var app = express() - .use(session({ secret: 'keyboard cat', cookie: { secure: true, maxAge: min }})) - .use(function(req, res) { res.json(req.secure); }); - app.enable('trust proxy'); - - request(app) + it('should use req.secure', function (done) { + request(this.server) .get('/') .set('X-Forwarded-Proto', 'https') - .expect(200, 'true', function(err, res){ - if (err) return done(err); - should(cookie(res)).not.be.empty; - done(); - }); + .set('X-Secure', 'true') + .expect(shouldSetCookie('connect.sid')) + .expect(200, 'true', done) }) }) }) - describe('genid option', function(){ - it('should reject non-function values', function(){ - session.bind(null, { genid: 'bogus!' }).should.throw(/genid.*must/); - }); + describe('cookie option', function () { + describe('when "path" set to "/foo/bar"', function () { + before(function () { + this.server = createServer({ cookie: { path: '/foo/bar' } }) + }) - it('should provide default generator', function(done){ - request(createServer()) - .get('/') - .expect(200, function (err, res) { - if (err) return done(err) - should(sid(res)).not.be.empty - done() + it('should not set cookie for "/" request', function (done) { + request(this.server) + .get('/') + .expect(shouldNotHaveHeader('Set-Cookie')) + .expect(200, done) }) - }); - it('should allow custom function', function(done){ - function genid() { return 'apple' } + 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) + }) - request(createServer({ genid: genid })) - .get('/') - .expect(200, function (err, res) { - if (err) return done(err) - should(sid(res)).equal('apple') - 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 encode unsafe chars', function(done){ - function genid() { return '%' } + 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) + }) - request(createServer({ genid: genid })) - .get('/') - .expect(200, function (err, res) { - if (err) return done(err) - should(sid(res)).equal('%25') - 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) + }) }) - }); + }) - it('should provide req argument', function(done){ - function genid(req) { return req.url } + 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' }}) + }) - request(createServer({ genid: genid })) - .get('/foo') - .expect(200, function (err, res) { - if (err) return done(err) - should(sid(res)).equal('%2Ffoo') - done() + 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('key option', function(){ - it('should default to "connect.sid"', function(done){ - request(createServer()) - .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 "proxy" is "false"', function () { + before(function () { + this.server = createServer({ proxy: false, cookie: { maxAge: 5, secure: 'auto' }}) + }) - it('should allow overriding', function(done){ - request(createServer({ key: 'sid' })) - .get('/') - .end(function(err, res){ - res.headers['set-cookie'].should.have.length(1); - res.headers['set-cookie'][0].should.match(/^sid/); - done(); - }); - }) - }) + 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('rolling option', function(){ - it('should default to false', function(done){ - var app = express(); - app.use(session({ secret: 'keyboard cat', cookie: { maxAge: min }})); - app.use(function(req, res, next){ - var save = req.session.save; - req.session.user = 'bob'; - res.end(); - }); + describe('when "proxy" is undefined', function() { + before(function () { + function setup (req) { + req.secure = JSON.parse(req.headers['x-secure']) + } - request(app) - .get('/') - .expect(200, function(err, res){ - if (err) return done(err); - should(cookie(res)).not.be.empty; - request(app) - .get('/') - .set('Cookie', cookie(res)) - .expect(200, function(err, res){ - if (err) return done(err); - should(cookie(res)).be.empty; - done(); - }); - }); - }); + function respond (req, res) { + res.end(String(req.secure)) + } - it('should force cookie on unmodified session', function(done){ - var app = express(); - app.use(session({ rolling: true, secret: 'keyboard cat', cookie: { maxAge: min }})); - app.use(function(req, res, next){ - var save = req.session.save; - req.session.user = 'bob'; - res.end(); - }); + this.server = createServer(setup, { cookie: { secure: 'auto' } }, respond) + }) - request(app) - .get('/') - .expect(200, function(err, res){ - if (err) return done(err); - should(cookie(res)).not.be.empty; - request(app) - .get('/') - .set('Cookie', cookie(res)) - .expect(200, function(err, res){ - if (err) return done(err); - should(cookie(res)).not.be.empty; - done(); - }); + 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(); + }); }); - }); - }); - describe('resave option', function(){ - it('should default to true', function(done){ - var count = 0; - var app = express(); - app.use(session({ secret: 'keyboard cat', cookie: { maxAge: min }})); - app.use(function(req, res, next){ - var save = req.session.save; - res.setHeader('x-count', count); - req.session.user = 'bob'; - req.session.save = function(fn){ - res.setHeader('x-count', ++count); - return save.call(this, fn); + 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 }; }; - res.end(); + 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(); + }); }); - request(app) - .get('/') - .expect('x-count', '1') - .expect(200, function(err, res){ - if (err) return done(err); - request(app) - .get('/') - .set('Cookie', cookie(res)) - .expect('x-count', '2') - .expect(200, done); + 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 force save on unmodified session', function(done){ - var count = 0; - var app = express(); - app.use(session({ resave: true, secret: 'keyboard cat', cookie: { maxAge: min }})); - app.use(function(req, res, next){ - var save = req.session.save; - res.setHeader('x-count', count); - req.session.user = 'bob'; - req.session.save = function(fn){ - res.setHeader('x-count', ++count); - return save.call(this, fn); + it('should 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' }; }; - res.end(); + 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); }); + }); - request(app) + + describe('when "sameSite" set to "auto"', function () { + describe('basic functionality', function () { + before(function () { + function setup (req) { + req.secure = JSON.parse(req.headers['x-secure']) + } + + function respond (req, res) { + res.end(String(req.secure)) + } + + this.server = createServer(setup, { cookie: { sameSite: 'auto' } }, respond) + }) + + it('should set SameSite=None for secure connections', function (done) { + request(this.server) + .get('/') + .set('X-Secure', 'true') + .expect(shouldSetCookieWithAttributeAndValue('connect.sid', 'SameSite', 'None')) + .expect(200, 'true', done) + }) + + it('should set SameSite=Lax for insecure connections', function (done) { + request(this.server) + .get('/') + .set('X-Secure', 'false') + .expect(shouldSetCookieWithAttributeAndValue('connect.sid', 'SameSite', 'Lax')) + .expect(200, 'false', done) + }) + }) + + describe('with proxy settings', function () { + describe('when "proxy" is "true"', function () { + before(function () { + this.server = createServer({ proxy: true, cookie: { sameSite: 'auto' }}) + }) + + it('should set SameSite=None when X-Forwarded-Proto is https', function (done) { + request(this.server) + .get('/') + .set('X-Forwarded-Proto', 'https') + .expect(shouldSetCookieWithAttributeAndValue('connect.sid', 'SameSite', 'None')) + .expect(200, done) + }) + + it('should set SameSite=Lax when X-Forwarded-Proto is http', function (done) { + request(this.server) + .get('/') + .set('X-Forwarded-Proto', 'http') + .expect(shouldSetCookieWithAttributeAndValue('connect.sid', 'SameSite', 'Lax')) + .expect(200, done) + }) + }) + + describe('when "proxy" is "false"', function () { + before(function () { + this.server = createServer({ proxy: false, cookie: { sameSite: 'auto' }}) + }) + + it('should set SameSite=Lax when X-Forwarded-Proto is https', function (done) { + request(this.server) + .get('/') + .set('X-Forwarded-Proto', 'https') + .expect(shouldSetCookieWithAttributeAndValue('connect.sid', 'SameSite', 'Lax')) + .expect(200, done) + }) + }) + }) + + describe('combined with secure auto', function() { + describe('when "secure" is "auto"', function () { + before(function () { + function setup (req) { + req.secure = JSON.parse(req.headers['x-secure']) + } + + function respond (req, res) { + res.end(String(req.secure)) + } + + this.server = createServer(setup, { cookie: { secure: 'auto', sameSite: 'auto' } }, respond) + }) + + it('should set both Secure and SameSite=None when secure', function (done) { + request(this.server) + .get('/') + .set('X-Secure', 'true') + .expect(shouldSetCookieWithAttribute('connect.sid', 'Secure')) + .expect(shouldSetCookieWithAttributeAndValue('connect.sid', 'SameSite', 'None')) + .expect(200, 'true', done) + }) + + it('should set neither Secure nor SameSite=None when insecure', function (done) { + request(this.server) + .get('/') + .set('X-Secure', 'false') + .expect(shouldSetCookieWithoutAttribute('connect.sid', 'Secure')) + .expect(shouldSetCookieWithAttributeAndValue('connect.sid', 'SameSite', 'Lax')) + .expect(200, 'false', done) + }) + }) + + describe('when "secure" is "false"', function () { + before(function () { + function setup (req) { + req.secure = JSON.parse(req.headers['x-secure']) + } + + function respond (req, res) { + res.end(String(req.secure)) + } + + this.server = createServer(setup, { cookie: { secure: false, sameSite: 'auto' } }, respond) + }) + + it('should set SameSite=None without Secure when secure', function (done) { + request(this.server) + .get('/') + .set('X-Secure', 'true') + .expect(shouldSetCookieWithoutAttribute('connect.sid', 'Secure')) + .expect(shouldSetCookieWithAttributeAndValue('connect.sid', 'SameSite', 'None')) + .expect(200, 'true', done) + }) + + it('should set SameSite=Lax without Secure when insecure', function (done) { + request(this.server) + .get('/') + .set('X-Secure', 'false') + .expect(shouldSetCookieWithoutAttribute('connect.sid', 'Secure')) + .expect(shouldSetCookieWithAttributeAndValue('connect.sid', 'SameSite', 'Lax')) + .expect(200, 'false', done) + }) + }) + + describe('when "secure" is "true"', function () { + before(function () { + function setup (req) { + req.secure = JSON.parse(req.headers['x-secure']) + } + + function respond (req, res) { + res.end(String(req.secure)) + } + + this.server = createServer(setup, { cookie: { secure: true, sameSite: 'auto' } }, respond) + }) + + it('should set both Secure and SameSite=None when secure', function (done) { + request(this.server) + .get('/') + .set('X-Secure', 'true') + .expect(shouldSetCookieWithAttribute('connect.sid', 'Secure')) + .expect(shouldSetCookieWithAttributeAndValue('connect.sid', 'SameSite', 'None')) + .expect(200, 'true', done) + }) + + it('should not set cookie when insecure', function (done) { + request(this.server) + .get('/') + .set('X-Secure', 'false') + .expect(shouldNotHaveHeader('Set-Cookie')) + .expect(200, 'false', done) + }) + }) + }) + }) + }) + + describe('genid option', function(){ + it('should reject non-function values', function(){ + assert.throws(session.bind(null, { genid: 'bogus!' }), /genid.*must/) + }); + + it('should provide default generator', function(done){ + request(createServer()) + .get('/') + .expect(shouldSetCookie('connect.sid')) + .expect(200, done) + }); + + it('should allow custom function', function(done){ + function genid() { return 'apple' } + + request(createServer({ genid: genid })) + .get('/') + .expect(shouldSetCookieToValue('connect.sid', 's%3Aapple.D8Y%2BpkTAmeR0PobOhY4G97PRW%2Bj7bUnP%2F5m6%2FOn1MCU')) + .expect(200, done) + }); + + it('should encode unsafe chars', function(done){ + function genid() { return '%' } + + request(createServer({ genid: genid })) + .get('/') + .expect(shouldSetCookieToValue('connect.sid', 's%3A%25.kzQ6x52kKVdF35Qh62AWk4ZekS28K5XYCXKa%2FOTZ01g')) + .expect(200, done) + }); + + it('should provide req argument', function(done){ + function genid(req) { return req.url } + + request(createServer({ genid: genid })) + .get('/foo') + .expect(shouldSetCookieToValue('connect.sid', 's%3A%2Ffoo.paEKBtAHbV5s1IB8B2zPnzAgYmmnRPIqObW4VRYj%2FMQ')) + .expect(200, done) + }); + }); + + describe('key option', function(){ + it('should default to "connect.sid"', function(done){ + request(createServer()) + .get('/') + .expect(shouldSetCookie('connect.sid')) + .expect(200, done) + }) + + it('should allow overriding', function(done){ + request(createServer({ key: 'session_id' })) + .get('/') + .expect(shouldSetCookie('session_id')) + .expect(200, done) + }) + }) + + describe('name option', function () { + it('should default to "connect.sid"', function (done) { + request(createServer()) + .get('/') + .expect(shouldSetCookie('connect.sid')) + .expect(200, done) + }) + + it('should set the cookie name', function (done) { + request(createServer({ name: 'session_id' })) .get('/') - .expect('x-count', '1') + .expect(shouldSetCookie('session_id')) + .expect(200, done) + }) + }) + + describe('rolling option', function(){ + it('should default to false', function(done){ + var server = createServer(null, function (req, res) { + req.session.user = 'bob' + res.end() + }) + + request(server) + .get('/') + .expect(shouldSetCookie('connect.sid')) .expect(200, function(err, res){ if (err) return done(err); - request(app) + request(server) .get('/') .set('Cookie', cookie(res)) - .expect('x-count', '2') - .expect(200, done); + .expect(shouldNotHaveHeader('Set-Cookie')) + .expect(200, done) }); }); - it('should prevent save on unmodified session', function(done){ - var count = 0; - var app = express(); - app.use(session({ resave: false, secret: 'keyboard cat', cookie: { maxAge: min }})); - app.use(function(req, res, next){ - var save = req.session.save; - res.setHeader('x-count', count); - req.session.user = 'bob'; - req.session.save = function(fn){ - res.setHeader('x-count', ++count); - return save.call(this, fn); - }; - res.end(); - }); + it('should force cookie on unmodified session', function(done){ + var server = createServer({ rolling: true }, function (req, res) { + req.session.user = 'bob' + res.end() + }) - request(app) + request(server) .get('/') - .expect('x-count', '1') + .expect(shouldSetCookie('connect.sid')) .expect(200, function(err, res){ if (err) return done(err); - request(app) + request(server) .get('/') .set('Cookie', cookie(res)) - .expect('x-count', '1') - .expect(200, done); + .expect(shouldSetCookie('connect.sid')) + .expect(200, done) }); }); - it('should still save modified session', function(done){ - var count = 0; - var app = express(); - app.use(session({ resave: false, secret: 'keyboard cat', cookie: { maxAge: min }})); - app.use(function(req, res, next){ - var save = req.session.save; - res.setHeader('x-count', count); - req.session.count = count; - req.session.user = 'bob'; - req.session.save = function(fn){ - res.setHeader('x-count', ++count); - return save.call(this, fn); - }; - res.end(); - }); + it('should not force cookie on uninitialized session if saveUninitialized option is set to false', function(done){ + var store = new session.MemoryStore() + var server = createServer({ store: store, rolling: true, saveUninitialized: false }) - request(app) + request(server) + .get('/') + .expect(shouldNotSetSessionInStore(store)) + .expect(shouldNotHaveHeader('Set-Cookie')) + .expect(200, done) + }); + + it('should force cookie and save uninitialized session if saveUninitialized option is set to true', function(done){ + var store = new session.MemoryStore() + var server = createServer({ store: store, rolling: true, saveUninitialized: true }) + + request(server) + .get('/') + .expect(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('x-count', '1') + .expect(shouldSetSessionInStore(store)) .expect(200, function(err, res){ if (err) return done(err); - request(app) + request(server) .get('/') .set('Cookie', cookie(res)) - .expect('x-count', '2') + .expect(shouldSetSessionInStore(store)) .expect(200, done); }); }); + + 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 count = 0; - var app = express(); - app.use(session({ secret: 'keyboard cat', cookie: { maxAge: min }})); - app.use(function(req, res, next){ - var save = req.session.save; - res.setHeader('x-count', count); - req.session.save = function(fn){ - res.setHeader('x-count', ++count); - return save.call(this, fn); - }; - res.end(); - }); + var store = new session.MemoryStore() + var server = createServer({ store: store }) - request(app) + request(server) .get('/') - .expect('x-count', '1') - .expect('set-cookie', /connect\.sid=/) + .expect(shouldSetSessionInStore(store)) + .expect(shouldSetCookie('connect.sid')) .expect(200, done); }); it('should force save of uninitialized session', function(done){ - var count = 0; - var app = express(); - app.use(session({ saveUninitialized: true, secret: 'keyboard cat', cookie: { maxAge: min }})); - app.use(function(req, res, next){ - var save = req.session.save; - res.setHeader('x-count', count); - req.session.save = function(fn){ - res.setHeader('x-count', ++count); - return save.call(this, fn); - }; - res.end(); - }); + var store = new session.MemoryStore() + var server = createServer({ store: store, saveUninitialized: true }) + + request(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); + }) - request(app) - .get('/') - .expect('x-count', '1') - .expect('set-cookie', /connect\.sid=/) - .expect(200, done); - }); + it('should sign cookies with first element', function (done) { + var store = new session.MemoryStore(); - it('should prevent save of uninitialized session', function(done){ - var count = 0; - var app = express(); - app.use(session({ saveUninitialized: false, secret: 'keyboard cat', cookie: { maxAge: min }})); - app.use(function(req, res, next){ - var save = req.session.save; - res.setHeader('x-count', count); - req.session.save = function(fn){ - res.setHeader('x-count', ++count); - return save.call(this, fn); - }; - res.end(); - }); + var server1 = createServer({ secret: ['keyboard cat', 'nyan cat'], store: store }, function (req, res) { + req.session.user = 'bob'; + res.end(req.session.user); + }); - request(app) - .get('/') - .expect('x-count', '0') - .expect(200, function(err, res){ - if (err) return done(err); - should(cookie(res)).be.empty; - done(); - }); - }); + var server2 = createServer({ secret: 'nyan cat', store: store }, function (req, res) { + res.end(String(req.session.user)); + }); - it('should still save modified session', function(done){ - var count = 0; - var app = express(); - app.use(session({ saveUninitialized: false, secret: 'keyboard cat', cookie: { maxAge: min }})); - app.use(function(req, res, next){ - var save = req.session.save; - res.setHeader('x-count', count); - req.session.count = count; - req.session.user = 'bob'; - req.session.save = function(fn){ - res.setHeader('x-count', ++count); - return save.call(this, fn); - }; - res.end(); + 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); + }); }); - request(app) - .get('/') - .expect('x-count', '1') - .expect('set-cookie', /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') - }) + it('should read cookies using all elements', function (done) { + var store = new session.MemoryStore(); - store.set = function destroy(sid, sess, callback) { - callback(new Error('boom!')) - } + var server1 = createServer({ secret: 'nyan cat', store: store }, function (req, res) { + req.session.user = 'bob'; + res.end(req.session.user); + }); - server.on('error', function onerror(err) { - err.message.should.equal('boom!') - cb() - }) + var server2 = createServer({ secret: ['keyboard cat', 'nyan cat'], store: store }, function (req, res) { + res.end(String(req.session.user)); + }); - request(server) - .get('/') - .expect(200, 'session saved', cb) + 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(){ - session.bind(null, { unset: 'bogus!' }).should.throw(/unset.*must/); + assert.throws(session.bind(null, { unset: 'bogus!' }), /unset.*must/) }); it('should default to keep', function(done){ var store = new session.MemoryStore(); - var app = express() - .use(session({ store: store, secret: 'keyboard cat' })) - .use(function(req, res, next){ - req.session.count = req.session.count || 0; - req.session.count++; - if (req.session.count === 2) req.session = null; - res.end(); - }); + var server = createServer({ store: store }, function (req, res) { + req.session.count = req.session.count || 0 + req.session.count++ + if (req.session.count === 2) req.session = null + res.end() + }) - request(app) + request(server) .get('/') .expect(200, function(err, res){ if (err) return done(err); store.length(function(err, len){ if (err) return done(err); - len.should.equal(1); - request(app) + assert.strictEqual(len, 1) + request(server) .get('/') .set('Cookie', cookie(res)) .expect(200, function(err, res){ if (err) return done(err); store.length(function(err, len){ if (err) return done(err); - len.should.equal(1); + assert.strictEqual(len, 1) done(); }); }); @@ -1026,30 +1623,28 @@ describe('session()', function(){ it('should allow destroy on req.session = null', function(done){ var store = new session.MemoryStore(); - var app = express() - .use(session({ store: store, unset: 'destroy', secret: 'keyboard cat' })) - .use(function(req, res, next){ - req.session.count = req.session.count || 0; - req.session.count++; - if (req.session.count === 2) req.session = null; - res.end(); - }); + var server = createServer({ store: store, unset: 'destroy' }, function (req, res) { + req.session.count = req.session.count || 0 + req.session.count++ + if (req.session.count === 2) req.session = null + res.end() + }) - request(app) + request(server) .get('/') .expect(200, function(err, res){ if (err) return done(err); store.length(function(err, len){ if (err) return done(err); - len.should.equal(1); - request(app) + assert.strictEqual(len, 1) + request(server) .get('/') .set('Cookie', cookie(res)) .expect(200, function(err, res){ if (err) return done(err); store.length(function(err, len){ if (err) return done(err); - len.should.equal(0); + assert.strictEqual(len, 0) done(); }); }); @@ -1059,21 +1654,19 @@ describe('session()', function(){ it('should not set cookie if initial session destroyed', function(done){ var store = new session.MemoryStore(); - var app = express() - .use(session({ store: store, unset: 'destroy', secret: 'keyboard cat' })) - .use(function(req, res, next){ - req.session = null; - res.end(); - }); + var server = createServer({ store: store, unset: 'destroy' }, function (req, res) { + req.session = null + res.end() + }) - request(app) + request(server) .get('/') + .expect(shouldNotHaveHeader('Set-Cookie')) .expect(200, function(err, res){ if (err) return done(err); store.length(function(err, len){ if (err) return done(err); - len.should.equal(0); - should(cookie(res)).be.empty; + assert.strictEqual(len, 0) done(); }); }); @@ -1092,7 +1685,8 @@ describe('session()', function(){ } server.on('error', function onerror(err) { - err.message.should.equal('boom!') + assert.ok(err) + assert.strictEqual(err.message, 'boom!') cb() }) @@ -1104,36 +1698,69 @@ describe('session()', function(){ describe('res.end patch', function () { it('should correctly handle res.end/res.write patched prior', function (done) { - var app = express() + function setup (req, res) { + utils.writePatch(res) + } - app.use(writePatch()) - app.use(createSession()) - app.use(function (req, res) { + function respond (req, res) { req.session.hit = true res.write('hello, ') res.end('world') - }) + } - request(app) + request(createServer(setup, null, respond)) .get('/') .expect(200, 'hello, world', done) }) it('should correctly handle res.end/res.write patched after', function (done) { - var app = express() - - app.use(createSession()) - app.use(writePatch()) - app.use(function (req, res) { + function respond (req, res) { + utils.writePatch(res) req.session.hit = true res.write('hello, ') res.end('world') - }) + } - request(app) + request(createServer(null, respond)) .get('/') .expect(200, 'hello, world', done) }) + + it('should error when res.end is called twice', function (done) { + var error1 = null + var error2 = null + var server = http.createServer(function (req, res) { + res.end() + + try { + res.setHeader('Content-Length', '3') + res.end('foo') + } catch (e) { + error1 = e + } + }) + + function respond (req, res) { + res.end() + + try { + res.setHeader('Content-Length', '3') + res.end('foo') + } catch (e) { + error2 = e + } + } + + request(server) + .get('/') + .end(function (err, res) { + if (err) return done(err) + request(createServer(null, respond)) + .get('/') + .expect(function () { assert.strictEqual((error1 && error1.message), (error2 && error2.message)) }) + .expect(res.statusCode, res.text, done) + }) + }) }) describe('req.session', function(){ @@ -1151,7 +1778,7 @@ describe('session()', function(){ if (err) return done(err) store.load(sid(res), function (err, sess) { if (err) return done(err) - should(sess).not.be.empty + assert.ok(sess) request(server) .get('/') .set('Cookie', cookie(res)) @@ -1162,99 +1789,133 @@ describe('session()', function(){ it('should only set-cookie when modified', function(done){ var modify = true; + var server = createServer(null, function (req, res) { + if (modify) { + req.session.count = req.session.count || 0 + req.session.count++ + } + res.end(req.session.count.toString()) + }) - var app = express() - .use(session({ secret: 'keyboard cat', cookie: { maxAge: min }})) - .use(function(req, res, next){ - if (modify) { - req.session.count = req.session.count || 0; - req.session.count++; - } - res.end(req.session.count.toString()); - }); - - request(app) + request(server) .get('/') - .end(function(err, res){ - res.text.should.equal('1'); - - request(app) + .expect(200, '1', function (err, res) { + if (err) return done(err) + request(server) .get('/') .set('Cookie', cookie(res)) - .end(function(err, res){ + .expect(200, '2', function (err, res) { + if (err) return done(err) var val = cookie(res); - res.text.should.equal('2'); modify = false; - request(app) + request(server) .get('/') .set('Cookie', val) - .end(function(err, res){ - should(sid(res)).be.empty; - res.text.should.equal('2'); + .expect(shouldNotHaveHeader('Set-Cookie')) + .expect(200, '2', function (err, res) { + if (err) return done(err) modify = true; - request(app) + request(server) .get('/') .set('Cookie', val) - .end(function(err, res){ - sid(res).should.not.be.empty; - res.text.should.equal('3'); - done(); - }); + .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 app = express() - .use(session({ secret: 'keyboard cat' })) - .use(function(req, res, next){ - req.session.destroy(function(err){ - if (err) throw err; - assert(!req.session, 'req.session after destroy'); - res.end(); - }); - }); + var server = createServer(null, function (req, res) { + req.session.destroy(function (err) { + if (err) res.statusCode = 500 + res.end(String(req.session)) + }) + }) - request(app) + request(server) .get('/') - .end(function(err, res){ - res.headers.should.not.have.property('set-cookie'); - done(); - }); + .expect(shouldNotHaveHeader('Set-Cookie')) + .expect(200, 'undefined', done) }) }) describe('.regenerate()', function(){ it('should destroy/replace the previous session', function(done){ - var app = express() - .use(session({ secret: 'keyboard cat', cookie: { maxAge: min }})) - .use(function(req, res, next){ - var id = req.session.id; - req.session.regenerate(function(err){ - if (err) throw err; - id.should.not.equal(req.session.id); - res.end(); - }); - }); + var server = createServer(null, function (req, res) { + var id = req.session.id + req.session.regenerate(function (err) { + if (err) res.statusCode = 500 + res.end(String(req.session.id === id)) + }) + }) - request(app) + request(server) .get('/') - .end(function(err, res){ + .expect(shouldSetCookie('connect.sid')) + .expect(200, function (err, res) { if (err) return done(err) - var id = sid(res) - request(app) + request(server) .get('/') .set('Cookie', cookie(res)) - .end(function(err, res){ - if (err) return done(err) - should(sid(res)).not.be.empty - should(sid(res)).should.not.equal(id) - done(); - }); + .expect(shouldSetCookie('connect.sid')) + .expect(shouldSetCookieToDifferentSessionId(sid(res))) + .expect(200, 'false', done) }); }) }) @@ -1328,6 +1989,49 @@ describe('session()', function(){ .expect(500, 'failed to load session', done) }) }) + + it('should not override an overridden `reload` in case of errors', function (done) { + var store = new session.MemoryStore() + var server = createServer({ store: store, resave: false }, function (req, res) { + if (req.url === '/') { + req.session.active = true + res.end('session created') + return + } + + store.clear(function (err) { + if (err) return done(err) + + // reload way too many times on top of each other, + // attempting to overflow the call stack + var iters = 20 + reload() + function reload () { + if (!--iters) { + res.end('ok') + return + } + + try { + req.session.reload(reload) + } catch (e) { + res.statusCode = 500 + res.end(e.message) + } + } + }) + }) + + request(server) + .get('/') + .expect(200, 'session created', function (err, res) { + if (err) return done(err) + request(server) + .get('/foo') + .set('Cookie', cookie(res)) + .expect(200, 'ok', done) + }) + }) }) describe('.save()', function () { @@ -1350,35 +2054,109 @@ describe('session()', function(){ }) it('should prevent end-of-request save', function (done) { - var count = 0 var store = new session.MemoryStore() - var server = createServer({ store: store }, function (req, res) { + 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.save(function (err) { - if (err) return res.end(err.message) - res.end('saved') - }) + req.session.touch() + res.end() }) - var _set = store.set - store.set = function set(sid, sess, callback) { - count++ - _set.call(store, sid, sess, callback) - } - request(server) .get('/') - .expect(200, 'saved', function (err, res) { + .expect(200, function (err, res) { if (err) return done(err) - count.should.equal(1) - count = 0 - request(server) - .get('/') - .set('Cookie', cookie(res)) - .expect(200, 'saved', function (err) { + var id = sid(res) + store.get(id, function (err, sess) { if (err) return done(err) - count.should.equal(1) - done() + 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) }) }) }) @@ -1387,317 +2165,389 @@ describe('session()', function(){ describe('.cookie', function(){ describe('.*', function(){ it('should serialize as parameters', function(done){ - var app = express() - .use(session({ secret: 'keyboard cat', proxy: true, cookie: { maxAge: min }})) - .use(function(req, res, next){ - req.session.cookie.httpOnly = false; - req.session.cookie.secure = true; - res.end(); - }); + var server = createServer({ proxy: true }, function (req, res) { + req.session.cookie.httpOnly = false + req.session.cookie.secure = true + res.end() + }) - request(app) + request(server) .get('/') .set('X-Forwarded-Proto', 'https') - .expect(200, function(err, res){ - if (err) return done(err); - var val = cookie(res); - should(val).not.containEql('HttpOnly'); - should(val).containEql('Secure'); - done(); - }); + .expect(shouldSetCookieWithoutAttribute('connect.sid', 'HttpOnly')) + .expect(shouldSetCookieWithAttribute('connect.sid', 'Secure')) + .expect(200, done) }) it('should default to a browser-session length cookie', function(done){ - var app = express() - .use(session({ secret: 'keyboard cat', cookie: { path: '/admin' }})) - .use(function(req, res, next){ - res.end(); - }); - - request(app) + request(createServer({ cookie: { path: '/admin' } })) .get('/admin') - .expect(200, function(err, res){ - if (err) return done(err); - var val = cookie(res); - should(val).not.containEql('Expires'); - done(); - }); + .expect(shouldSetCookieWithoutAttribute('connect.sid', 'Expires')) + .expect(200, done) }) it('should Set-Cookie only once for browser-session cookies', function(done){ - var app = express() - .use(session({ secret: 'keyboard cat', cookie: { path: '/admin' }})) - .use(function(req, res, next){ - res.end(); - }); + var server = createServer({ cookie: { path: '/admin' } }) - request(app) + request(server) .get('/admin/foo') - .end(function(err, res){ - res.headers.should.have.property('set-cookie'); - - request(app) + .expect(shouldSetCookie('connect.sid')) + .expect(200, function (err, res) { + if (err) return done(err) + request(server) .get('/admin') .set('Cookie', cookie(res)) - .end(function(err, res){ - res.headers.should.not.have.property('set-cookie'); - done(); - }) + .expect(shouldNotHaveHeader('Set-Cookie')) + .expect(200, done) }); }) it('should override defaults', function(done){ - var app = express() - .use(session({ secret: 'keyboard cat', cookie: { path: '/admin', httpOnly: false, secure: true, maxAge: 5000 }})) - .use(function(req, res, next){ - req.session.cookie.secure = false; - res.end(); - }); + var opts = { + httpOnly: false, + maxAge: 5000, + path: '/admin', + priority: 'high', + secure: true + } + var server = createServer({ cookie: opts }, function (req, res) { + req.session.cookie.secure = false + res.end() + }) - request(app) - .get('/admin') - .expect(200, function(err, res){ - if (err) return done(err); - var val = cookie(res); - should(val).not.containEql('HttpOnly'); - should(val).not.containEql('Secure'); - should(val).containEql('Path=/admin'); - should(val).containEql('Expires'); - done(); - }); + request(server) + .get('/admin') + .expect(shouldSetCookieWithAttribute('connect.sid', 'Expires')) + .expect(shouldSetCookieWithoutAttribute('connect.sid', 'HttpOnly')) + .expect(shouldSetCookieWithAttributeAndValue('connect.sid', 'Path', '/admin')) + .expect(shouldSetCookieWithoutAttribute('connect.sid', 'Secure')) + .expect(shouldSetCookieWithAttributeAndValue('connect.sid', 'Priority', 'High')) + .expect(200, done) }) - it('should 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 forward errors setting cookie', function (done) { + var cb = after(2, done) + var server = createServer({ cookie: { expires: new Date(NaN) } }, function (req, res) { + res.end() + }) - var app = express() - .use(session({ secret: 'keyboard cat' })) - .use(function(req, res, next){ - var cookie = new Cookie(); - res.setHeader('Set-Cookie', cookie.serialize('previous', 'cookieValue')); - res.end(); - }); + server.on('error', function onerror (err) { + assert.ok(err) + assert.strictEqual(err.message, 'option expires is invalid') + cb() + }) - request(app) - .get('/') - .end(function(err, res){ - getPreviousCookie(res).should.equal('cookieValue'); - done(); - }); + request(server) + .get('/admin') + .expect(200, cb) }) - }) - describe('.secure', function(){ - it('should not set-cookie when insecure', function(done){ - var app = express() - .use(session({ secret: 'keyboard cat' })) - .use(function(req, res, next){ - req.session.cookie.secure = true; - res.end(); - }); + 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(app) + request(server) .get('/') - .end(function(err, res){ - res.headers.should.not.have.property('set-cookie'); - done(); - }); + .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('when the pathname does not match cookie.path', function(){ - it('should not set-cookie', function(done){ - var app = express() - .use(session({ secret: 'keyboard cat', cookie: { path: '/foo/bar' }})) - .use(function(req, res, next){ - if (!req.session) { - return res.end(); - } - req.session.foo = Math.random(); - res.end(); - }); + describe('.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(app) - .get('/') - .end(function(err, res){ - res.status.should.equal(200); - res.headers.should.not.have.property('set-cookie'); - done(); - }); + 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 not set-cookie even for FQDN', function(done){ - var app = express() - .use(session({ secret: 'keyboard cat', cookie: { path: '/foo/bar' }})) - .use(function(req, res, next){ - if (!req.session) { - return res.end(); - } + it('should 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)) + }) - req.session.foo = Math.random(); - res.end(); - }); + 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) + }) + }) - 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(); - }); + 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('when the pathname does match cookie.path', function(){ - it('should set-cookie', function(done){ - var app = express() - .use(session({ secret: 'keyboard cat', cookie: { path: '/foo/bar' }})) - .use(function(req, res, next){ - req.session.foo = Math.random(); - res.end(); - }); + describe('.secure', function(){ + var app - request(app) - .get('/foo/bar/baz') - .end(function(err, res){ - res.headers.should.have.property('set-cookie'); - done(); - }); + before(function () { + app = createRequestListener({ secret: 'keyboard cat', cookie: { secure: true } }) }) - it('should set-cookie even for FQDN', function(done){ - var app = express() - .use(session({ secret: 'keyboard cat', cookie: { path: '/foo/bar' }})) - .use(function(req, res, next){ - req.session.foo = Math.random(); - res.end(); - }); + 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 + }) - 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(); - }); + 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) }) - }) - describe('.maxAge', function(){ - var val; - var app = express() - .use(session({ secret: 'keyboard cat', cookie: { maxAge: 2000 }})) - .use(function(req, res, next){ - req.session.count = req.session.count || 0; - req.session.count++; - if (req.session.count == 2) req.session.cookie.maxAge = 5000; - if (req.session.count == 3) req.session.cookie.maxAge = 3000000000; - res.end(req.session.count.toString()); - }); + it('should not set-cookie when insecure', function(done){ + var server = http.createServer(app) - it('should set relative in milliseconds', function(done){ - request(app) + request(server) .get('/') - .end(function(err, res){ - var a = new Date(expires(res)) - , b = new Date; - - val = cookie(res); - - 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(); - }); - }); + .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()) + }) - it('should modify cookie when changed', function(done){ - request(app) + request(ctx.server) .get('/') - .set('Cookie', val) - .end(function(err, res){ - var a = new Date(expires(res)) - , b = new Date; - - val = cookie(res); - - 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(); - }); - }); + .end(function (err, res) { + ctx.cookie = res && cookie(res) + done(err) + }) + }) - it('should modify cookie when changed to large value', function(done){ - request(app) + it('should set cookie expires relative to maxAge', function (done) { + request(this.server) .get('/') - .set('Cookie', val) - .end(function(err, res){ - var a = new Date(expires(res)) - , b = new Date; + .set('Cookie', this.cookie) + .expect(shouldSetCookieToExpireIn('connect.sid', 2000)) + .expect(200, '1', done) + }) - val = cookie(res); + 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) + }) - var delta = a.valueOf() - b.valueOf(); - (delta > 2999999000 && delta < 3000000000).should.be.ok; - res.text.should.equal('3'); - 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 app = express() - .use(session({ secret: 'keyboard cat' })) - .use(function(req, res, next){ - req.session.cookie.expires = new Date(0); - res.end(); - }); - - request(app) + var server = createServer(null, function (req, res) { + req.session.cookie.expires = new Date(0) + res.end() + }) + + request(server) .get('/') - .end(function(err, res){ - expires(res).should.equal('Thu, 01 Jan 1970 00:00:00 GMT'); - done(); - }); + .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 app = express() - .use(session({ secret: 'keyboard cat' })) - .use(function(req, res, next){ - req.session.cookie.expires = null; - res.end(); - }); - - request(app) + 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(200, function(err, res){ + .expect(shouldSetCookieWithoutAttribute('connect.sid', 'Expires')) + .expect(200, function (err, res) { if (err) return done(err); - var val = cookie(res); - should(val).not.containEql('Expires='); - done(); + 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 } }) + + request(server) + .get('/') + .expect(shouldSetCookieWithoutAttribute('connect.sid', 'Partitioned')) + .expect(200, done) + }) + }) + + describe('when "true"', function () { + it('should set partitioned attribute', function (done) { + var server = createServer({ cookie: { partitioned: true } }) + + request(server) + .get('/') + .expect(shouldSetCookieWithAttribute('connect.sid', 'Partitioned')) + .expect(200, done) + }) + }) + }) }) }) @@ -1744,7 +2594,7 @@ describe('session()', function(){ var app = express() .use(cookieParser()) .use(function(req, res, next){ req.headers.cookie = 'foo=bar'; next() }) - .use(session({ secret: 'keyboard cat' })) + .use(createSession()) .use(function(req, res, next){ req.session.count = req.session.count || 0 req.session.count++ @@ -1766,7 +2616,7 @@ describe('session()', function(){ var app = express() .use(cookieParser()) .use(function(req, res, next){ req.headers.cookie = 'foo=bar'; next() }) - .use(session({ secret: 'keyboard cat', key: 'sessid' })) + .use(createSession({ key: 'sessid' })) .use(function(req, res, next){ req.session.count = req.session.count || 0 req.session.count++ @@ -1788,7 +2638,7 @@ describe('session()', function(){ var app = express() .use(cookieParser()) .use(function(req, res, next){ req.headers.cookie = 'foo=bar'; next() }) - .use(session({ secret: 'keyboard cat', key: 'sessid' })) + .use(createSession({ key: 'sessid' })) .use(function(req, res, next){ req.session.count = req.session.count || 0 req.session.count++ @@ -1811,7 +2661,7 @@ describe('session()', function(){ var app = express() .use(cookieParser('keyboard cat')) .use(function(req, res, next){ delete req.headers.cookie; next() }) - .use(session()) + .use(createSession()) .use(function(req, res, next){ req.session.count = req.session.count || 0 req.session.count++ @@ -1831,16 +2681,143 @@ describe('session()', function(){ }) }) +describe('path matching (RFC 6265)', function () { + describe('when "path" is "/" (root path)', function () { + before(function () { + this.server = createServer({ cookie: { path: '/' } }) + }) + + it('should set cookie when request-path is "/" (root path)', function (done) { + // RFC 6265 5.1.4: "The cookie-path and the request-path are identical." + request(this.server) + .get('/') + .expect(shouldSetCookie('connect.sid')) + .expect(200, done) + }) + + it('should set cookie when request-path is any path ("/foo")', function (done) { + // RFC 6265 5.1.4: "The cookie-path is a prefix of the request-path, and the last + // character of the cookie-path is %x2F ("/")." + request(this.server) + .get('/foo') + .expect(shouldSetCookie('connect.sid')) + .expect(200, done) + }) + + it('should set cookie when request-path has multiple segments ("/foo/bar/baz")', function (done) { + // RFC 6265 5.1.4: "The cookie-path is a prefix of the request-path, and the last + // character of the cookie-path is %x2F ("/")." + request(this.server) + .get('/foo/bar/baz') + .expect(shouldSetCookie('connect.sid')) + .expect(200, done) + }) + }) + + describe('when "path" is "/admin"', function () { + before(function () { + this.server = createServer({ cookie: { path: '/admin' } }) + }) + + it('should set cookie when request-path and cookie-path are identical ("/admin")', function (done) { + // RFC 6265 5.1.4: "The cookie-path and the request-path are identical." + request(this.server) + .get('/admin') + .expect(shouldSetCookie('connect.sid')) + .expect(200, done) + }) + + it('should set cookie when cookie-path is prefix and last char is "/" ("/admin/")', function (done) { + // RFC 6265 5.1.4: "The cookie-path is a prefix of the request-path, and the last + // character of the cookie-path is %x2F ("/")." + request(this.server) + .get('/admin/') + .expect(shouldSetCookie('connect.sid')) + .expect(200, done) + }) + + it('should set cookie when cookie-path is prefix and next char is "/" ("/admin/users")', function (done) { + // RFC 6265 5.1.4: "The cookie-path is a prefix of the request-path, and the first + // character of the request-path that is not included in the cookie-path is a %x2F ("/") character." + request(this.server) + .get('/admin/users') + .expect(shouldSetCookie('connect.sid')) + .expect(200, done) + }) + + it('should NOT set cookie when cookie-path is not a prefix ("/administrator")', function (done) { + // RFC 6265 5.1.4: None of the path-match conditions are met + request(this.server) + .get('/administrator') + .expect(shouldNotHaveHeader('Set-Cookie')) + .expect(200, done) + }) + }) + + describe('when "path" is "/admin/" (trailing slash)', function () { + before(function () { + this.server = createServer({ cookie: { path: '/admin/' } }) + }) + + it('should set cookie when cookie-path is prefix and last char is "/" ("/admin/x")', function (done) { + // RFC 6265 5.1.4: "The cookie-path is a prefix of the request-path, and the last + // character of the cookie-path is %x2F ("/")." + request(this.server) + .get('/admin/x') + .expect(shouldSetCookie('connect.sid')) + .expect(200, done) + }) + + it('should NOT set cookie when request-path is not prefixed by cookie-path ("/admin")', function (done) { + // RFC 6265 5.1.4: cookie-path "/admin/" is not a prefix of request-path "/admin" + request(this.server) + .get('/admin') + .expect(shouldNotHaveHeader('Set-Cookie')) + .expect(200, done) + }) + + it('should NOT set cookie when cookie-path is not a prefix ("/administrator")', function (done) { + // RFC 6265 5.1.4: None of the path-match conditions are met: + // 1. The paths are not identical + // 2. "/admin/" is not a prefix of "/administrator" + // 3. The prefix condition with next character "/" is not applicable + request(this.server) + .get('/administrator') + .expect(shouldNotHaveHeader('Set-Cookie')) + .expect(200, done) + }) + }) +}) + + function cookie(res) { var setCookie = res.headers['set-cookie']; return (setCookie && setCookie[0]) || undefined; } -function createServer(opts, fn) { +function createServer (options, respond) { + var fn = respond + var opts = options + var server = http.createServer() + + // setup, options, respond + if (typeof arguments[0] === 'function') { + opts = arguments[1] + fn = arguments[2] + + server.on('request', arguments[0]) + } + + return server.on('request', createRequestListener(opts, fn)) +} + +function createRequestListener(opts, fn) { var _session = createSession(opts) var respond = fn || end - var server = http.createServer(function (req, res) { + return function onRequest(req, res) { + var server = this + _session(req, res, function (err) { if (err && !res._header) { res.statusCode = err.status || 500 @@ -1855,9 +2832,7 @@ function createServer(opts, fn) { respond(req, res) }) - }) - - return server + } } function createSession(opts) { @@ -1878,56 +2853,138 @@ function end(req, res) { res.end() } -function expires(res) { - var match = /Expires=([^;]+)/.exec(cookie(res)); - return match ? match[1] : undefined; +function expires (res) { + var header = cookie(res) + return header && utils.parseSetCookie(header).expires +} + +function mountAt (path) { + return function (req, res) { + if (req.url.indexOf(path) === 0) { + req.originalUrl = req.url + req.url = req.url.slice(path.length) + } + } +} + +function shouldNotHaveHeader(header) { + return function (res) { + assert.ok(!(header.toLowerCase() in res.headers), 'should not have ' + header + ' header') + } } -function sid(res) { - var match = /^[^=]+=s%3A([^;\.]+)[\.;]/.exec(cookie(res)) - var val = match ? match[1] : undefined - return val +function shouldNotSetSessionInStore(store) { + var _set = store.set + var count = 0 + + store.set = function set () { + count++ + return _set.apply(this, arguments) + } + + return function () { + assert.ok(count === 0, 'should not set session in store') + } } -function writePatch() { - var ended = false - return function addWritePatch(req, res, next) { - var _end = res.end - var _write = res.write +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) + } +} - res.end = function end() { - ended = true - return _end.apply(this, arguments) - } +function shouldSetCookieToDifferentSessionId (id) { + return function (res) { + assert.notStrictEqual(sid(res), id) + } +} - res.write = function write() { - if (ended) { - throw new Error('write after end') - } +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') + } +} - return _write.apply(this, arguments) - } +function shouldSetCookieToValue (name, val) { + return function (res) { + var header = cookie(res) + var data = header && utils.parseSetCookie(header) + assert.ok(header, 'should have a cookie header') + assert.strictEqual(data.name, name, 'should set cookie ' + name) + assert.strictEqual(data.value, val, 'should set cookie ' + name + ' to ' + val) + } +} + +function shouldSetCookieWithAttribute (name, attrib) { + return function (res) { + var header = cookie(res) + var data = header && utils.parseSetCookie(header) + assert.ok(header, 'should have a cookie header') + assert.strictEqual(data.name, name, 'should set cookie ' + name) + assert.ok((attrib.toLowerCase() in data), 'should set cookie with attribute ' + attrib) + } +} - next() +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) } } -function SyncStore() { - this.sessions = Object.create(null); +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) + } } -SyncStore.prototype.__proto__ = session.Store.prototype; +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] + } -SyncStore.prototype.destroy = function destroy(sid, callback) { - delete this.sessions[sid]; - callback(); -}; + setTimeout(_set.bind.apply(_set, args), delay) + } -SyncStore.prototype.get = function get(sid, callback) { - callback(null, JSON.parse(this.sessions[sid])); -}; + return function () { + assert.ok(count === 1, 'should set session in store') + } +} -SyncStore.prototype.set = function set(sid, sess, callback) { - this.sessions[sid] = JSON.stringify(sess); - callback(); -}; +function sid (res) { + var header = cookie(res) + var data = header && utils.parseSetCookie(header) + var value = data && unescape(data.value) + var sid = value && value.substring(2, value.indexOf('.')) + return sid || undefined +} diff --git a/test/support/env.js b/test/support/env.js new file mode 100644 index 00000000..fba1bd89 --- /dev/null +++ b/test/support/env.js @@ -0,0 +1,2 @@ + +process.env.NO_DEPRECATION = 'express-session'; diff --git a/test/support/gencert.sh b/test/support/gencert.sh new file mode 100755 index 00000000..cfdda070 --- /dev/null +++ b/test/support/gencert.sh @@ -0,0 +1,5 @@ +#! /bin/sh +set -ex + +openssl req -x509 -nodes -newkey rsa:2048 -keyout ./test/fixtures/server.key -out ./test/fixtures/server.crt -days 3650 \ +-subj "/C=US/ST=Illinois/L=Chicago/O=node-express-session/CN=express-session.local" diff --git a/test/support/smart-store.js b/test/support/smart-store.js new file mode 100644 index 00000000..8b224fdd --- /dev/null +++ b/test/support/smart-store.js @@ -0,0 +1,54 @@ +'use strict' + +var session = require('../../') +var util = require('util') + +/* istanbul ignore next */ +var defer = typeof setImmediate === 'function' + ? setImmediate + : function(fn){ process.nextTick(fn.bind.apply(fn, arguments)) } + +module.exports = SmartStore + +function SmartStore () { + session.Store.call(this) + this.sessions = Object.create(null) +} + +util.inherits(SmartStore, session.Store) + +SmartStore.prototype.destroy = function destroy (sid, callback) { + delete this.sessions[sid] + defer(callback, null) +} + +SmartStore.prototype.get = function get (sid, callback) { + var sess = this.sessions[sid] + + if (!sess) { + return + } + + // parse + sess = JSON.parse(sess) + + if (sess.cookie) { + // expand expires into Date object + sess.cookie.expires = typeof sess.cookie.expires === 'string' + ? new Date(sess.cookie.expires) + : sess.cookie.expires + + // destroy expired session + if (sess.cookie.expires && sess.cookie.expires <= Date.now()) { + delete this.sessions[sid] + sess = null + } + } + + defer(callback, null, sess) +} + +SmartStore.prototype.set = function set (sid, sess, callback) { + this.sessions[sid] = JSON.stringify(sess) + defer(callback, null) +} diff --git a/test/support/sync-store.js b/test/support/sync-store.js new file mode 100644 index 00000000..b45dc9a8 --- /dev/null +++ b/test/support/sync-store.js @@ -0,0 +1,27 @@ +'use strict' + +var session = require('../../') +var util = require('util') + +module.exports = SyncStore + +function SyncStore () { + session.Store.call(this) + this.sessions = Object.create(null) +} + +util.inherits(SyncStore, session.Store) + +SyncStore.prototype.destroy = function destroy (sid, callback) { + delete this.sessions[sid] + callback() +} + +SyncStore.prototype.get = function get (sid, callback) { + callback(null, JSON.parse(this.sessions[sid])) +} + +SyncStore.prototype.set = function set (sid, sess, callback) { + this.sessions[sid] = JSON.stringify(sess) + callback() +} diff --git a/test/support/utils.js b/test/support/utils.js new file mode 100644 index 00000000..0f13ba6a --- /dev/null +++ b/test/support/utils.js @@ -0,0 +1,42 @@ +'use strict' + +module.exports.parseSetCookie = parseSetCookie +module.exports.writePatch = writePatch + +function parseSetCookie (header) { + var match + var pairs = [] + var pattern = /\s*([^=;]+)(?:=([^;]*);?|;|$)/g + + while ((match = pattern.exec(header))) { + pairs.push({ name: match[1], value: match[2] }) + } + + var cookie = pairs.shift() + + for (var i = 0; i < pairs.length; i++) { + match = pairs[i] + cookie[match.name.toLowerCase()] = (match.value || true) + } + + return cookie +} + +function writePatch (res) { + var _end = res.end + var _write = res.write + var ended = false + + res.end = function end () { + ended = true + return _end.apply(this, arguments) + } + + res.write = function write () { + if (ended) { + throw new Error('write after end') + } + + return _write.apply(this, arguments) + } +}