├── VERSION ├── NOTICE ├── .gitmodules ├── .yardopts ├── .gitignore ├── .github ├── PULL_REQUEST_TEMPLATE.md ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── documentation.yml │ ├── feature-request.yml │ └── bug-report.yml └── workflows │ ├── handle-stale-discussions.yml │ ├── closed-issue-message.yml │ ├── ci.yml │ └── stale_issues.yml ├── lib ├── aws │ └── session_store │ │ └── dynamo_db │ │ ├── locking.rb │ │ ├── errors.rb │ │ ├── locking │ │ ├── null.rb │ │ ├── base.rb │ │ └── pessimistic.rb │ │ ├── errors │ │ ├── base_handler.rb │ │ └── default_handler.rb │ │ ├── table.rb │ │ ├── garbage_collection.rb │ │ ├── rack_middleware.rb │ │ └── configuration.rb └── aws-sessionstore-dynamodb.rb ├── CODE_OF_CONDUCT.md ├── doc-src └── templates │ └── default │ └── layout │ └── html │ ├── footer.erb │ └── layout.erb ├── Gemfile ├── .rubocop.yml ├── spec ├── spec_helper.rb └── aws │ └── session_store │ └── dynamo_db │ ├── table_spec.rb │ ├── locking │ └── threaded_sessions_spec.rb │ ├── error │ └── default_handler_spec.rb │ ├── garbage_collection_spec.rb │ ├── rack_middleware_integration_spec.rb │ ├── configuration_spec.rb │ └── rack_middleware_spec.rb ├── Rakefile ├── aws-sessionstore-dynamodb.gemspec ├── CHANGELOG.md ├── CONTRIBUTING.md ├── README.md └── LICENSE /VERSION: -------------------------------------------------------------------------------- 1 | 3.0.1 2 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "tasks/release"] 2 | path = tasks/release 3 | url = git@github.com:aws/aws-sdk-ruby-release-tools.git 4 | -------------------------------------------------------------------------------- /.yardopts: -------------------------------------------------------------------------------- 1 | --title 'AWS DynamoDB Session Store' 2 | --template-path doc-src/templates 3 | --hide-api private 4 | --plugin sitemap 5 | --markup markdown -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .byebug_history 3 | 4 | Gemfile.lock 5 | gemfiles/*.gemfile.lock 6 | *.gem 7 | 8 | coverage/ 9 | .yardoc/ 10 | doc/ 11 | docs.zip 12 | .release/ 13 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | *Issue #, if available:* 2 | 3 | *Description of changes:* 4 | 5 | 6 | By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license. 7 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | --- 2 | blank_issues_enabled: false 3 | contact_links: 4 | - name: 💬 General Question 5 | url: https://github.com/aws/aws-sdk-ruby/discussions/categories/q-a 6 | about: Please ask and answer questions as a discussion thread -------------------------------------------------------------------------------- /lib/aws/session_store/dynamo_db/locking.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Aws::SessionStore::DynamoDB 4 | # @api private 5 | module Locking; end 6 | end 7 | 8 | require_relative 'locking/base' 9 | require_relative 'locking/null' 10 | require_relative 'locking/pessimistic' 11 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | ## Code of Conduct 2 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 3 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 4 | opensource-codeofconduct@amazon.com with any additional questions or comments. 5 | -------------------------------------------------------------------------------- /doc-src/templates/default/layout/html/footer.erb: -------------------------------------------------------------------------------- 1 | 7 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | gemspec 6 | 7 | gem 'rake', require: false 8 | 9 | group :development do 10 | gem 'byebug', platforms: :ruby 11 | gem 'rubocop' 12 | end 13 | 14 | group :docs do 15 | gem 'yard' 16 | gem 'yard-sitemap', '~> 1.0' 17 | end 18 | 19 | group :release do 20 | gem 'octokit' 21 | end 22 | 23 | group :test do 24 | gem 'rack-test' 25 | gem 'rails' 26 | gem 'rexml' 27 | gem 'rspec' 28 | gem 'simplecov' 29 | end 30 | -------------------------------------------------------------------------------- /.github/workflows/handle-stale-discussions.yml: -------------------------------------------------------------------------------- 1 | name: HandleStaleDiscussions 2 | on: 3 | schedule: 4 | - cron: '0 */4 * * *' 5 | discussion_comment: 6 | types: [created] 7 | 8 | jobs: 9 | handle-stale-discussions: 10 | name: Handle stale discussions 11 | runs-on: ubuntu-latest 12 | permissions: 13 | discussions: write 14 | steps: 15 | - name: Stale discussions action 16 | uses: aws-github-ops/handle-stale-discussions@v1 17 | env: 18 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} -------------------------------------------------------------------------------- /lib/aws-sessionstore-dynamodb.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Aws 4 | module SessionStore 5 | # Namespace for DynamoDB rack session storage. 6 | module DynamoDB 7 | VERSION = File.read(File.expand_path('../VERSION', __dir__)).strip 8 | end 9 | end 10 | end 11 | 12 | require_relative 'aws/session_store/dynamo_db/configuration' 13 | require_relative 'aws/session_store/dynamo_db/errors' 14 | require_relative 'aws/session_store/dynamo_db/garbage_collection' 15 | require_relative 'aws/session_store/dynamo_db/locking' 16 | require_relative 'aws/session_store/dynamo_db/rack_middleware' 17 | require_relative 'aws/session_store/dynamo_db/table' 18 | -------------------------------------------------------------------------------- /.github/workflows/closed-issue-message.yml: -------------------------------------------------------------------------------- 1 | name: Closed Issue Message 2 | on: 3 | issues: 4 | types: [closed] 5 | permissions: {} 6 | jobs: 7 | auto_comment: 8 | permissions: 9 | issues: write # to comment on issues 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: aws-actions/closed-issue-message@v1 13 | with: 14 | # These inputs are both required 15 | repo-token: "${{ secrets.GITHUB_TOKEN }}" 16 | message: | 17 | This issue is now closed. Comments on closed issues are hard for our team to see. 18 | If you need more assistance, please open a new issue that references this one. 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/documentation.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: "📕 Documentation Issue" 3 | description: Report an issue in the API Reference documentation or Developer Guide 4 | title: "(short issue description)" 5 | labels: [documentation, needs-triage] 6 | assignees: [] 7 | body: 8 | - type: textarea 9 | id: description 10 | attributes: 11 | label: Describe the issue 12 | description: A clear and concise description of the issue. 13 | validations: 14 | required: true 15 | 16 | - type: textarea 17 | id: links 18 | attributes: 19 | label: Links 20 | description: | 21 | Include links to affected documentation page(s). 22 | validations: 23 | required: true 24 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | # inherit_from: .rubocop_todo.yml 2 | 3 | AllCops: 4 | Exclude: 5 | - 'tasks/release/**/*' 6 | - 'test/dummy/db/migrate' 7 | NewCops: enable 8 | SuggestExtensions: false 9 | TargetRubyVersion: 2.7 10 | 11 | Gemspec/RequireMFA: 12 | Enabled: false 13 | 14 | Metrics/BlockLength: 15 | Exclude: 16 | - 'spec/**/*' 17 | 18 | Metrics/ClassLength: 19 | Max: 150 20 | 21 | Naming/AccessorMethodName: 22 | Enabled: false 23 | 24 | Naming/FileName: 25 | Exclude: 26 | - 'lib/aws-sessionstore-dynamodb.rb' 27 | 28 | Security/MarshalLoad: 29 | Exclude: 30 | - 'lib/aws/session_store/dynamo_db/locking/base.rb' 31 | 32 | Style/ClassAndModuleChildren: 33 | Enabled: false 34 | 35 | Style/GlobalVars: 36 | AllowedVariables: 37 | - $VERSION 38 | - $REPO_ROOT 39 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'simplecov' 4 | SimpleCov.start { add_filter 'spec' } 5 | 6 | $LOAD_PATH << File.join(File.dirname(File.dirname(__FILE__)), 'lib') 7 | 8 | require 'rack/test' 9 | require 'rspec' 10 | require 'aws-sessionstore-dynamodb' 11 | 12 | # Default Rack application 13 | class MultiplierApplication 14 | def call(env) 15 | if env['rack.session'][:multiplier] 16 | env['rack.session'][:multiplier] *= 2 17 | else 18 | env['rack.session'][:multiplier] = 1 19 | end 20 | [200, { 'Content-Type' => 'text/plain' }, ['All good!']] 21 | end 22 | end 23 | 24 | RSpec.configure do |c| 25 | c.before(:all, integration: true) do 26 | opts = { table_name: 'sessionstore-integration-test' } 27 | Aws::SessionStore::DynamoDB::Table.create_table(opts) 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/aws/session_store/dynamo_db/errors.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Aws::SessionStore::DynamoDB::Errors 4 | # This error is raised when no secret key is provided. 5 | class MissingSecretKeyError < RuntimeError 6 | def initialize(msg = 'No secret key provided!') 7 | super 8 | end 9 | end 10 | 11 | # This error is raised when an invalid session ID is provided. 12 | class InvalidIDError < RuntimeError 13 | def initialize(msg = 'Corrupt Session ID!') 14 | super 15 | end 16 | end 17 | 18 | # This error is raised when the maximum time spent to acquire lock has been exceeded. 19 | class LockWaitTimeoutError < RuntimeError 20 | def initialize(msg = 'Maximum time spent to acquire lock has been exceeded!') 21 | super 22 | end 23 | end 24 | end 25 | 26 | require_relative 'errors/base_handler' 27 | require_relative 'errors/default_handler' 28 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rspec/core/rake_task' 4 | require 'rake/testtask' 5 | require 'rubocop/rake_task' 6 | 7 | $REPO_ROOT = File.dirname(__FILE__) 8 | $LOAD_PATH.unshift(File.join($REPO_ROOT, 'lib')) 9 | $VERSION = ENV['VERSION'] || File.read(File.join($REPO_ROOT, 'VERSION')).strip 10 | 11 | Dir.glob('**/*.rake').each do |task_file| 12 | load task_file 13 | end 14 | 15 | desc 'Runs unit tests' 16 | RSpec::Core::RakeTask.new do |t| 17 | t.rspec_opts = '--tag ~integration --format documentation' 18 | end 19 | 20 | desc 'Runs integration tests' 21 | RSpec::Core::RakeTask.new('spec:integration') do |t| 22 | t.rspec_opts = '--tag integration --format documentation' 23 | end 24 | 25 | desc 'Runs unit and integration tests' 26 | task 'test' => [:spec, 'spec:integration'] 27 | 28 | RuboCop::RakeTask.new 29 | 30 | task default: :spec 31 | task 'release:test' => [:spec, 'spec:integration'] 32 | -------------------------------------------------------------------------------- /doc-src/templates/default/layout/html/layout.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | <%= erb(:headers) %> 5 | 6 | 7 | 8 | 9 | 10 | 11 | 15 | 16 |
17 | 22 | 23 |
24 | 25 | <%= yieldall %> 26 |
27 | 28 | <%= erb(:footer) %> 29 |
30 | 31 | 32 | -------------------------------------------------------------------------------- /lib/aws/session_store/dynamo_db/locking/null.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Aws::SessionStore::DynamoDB::Locking 4 | # This class gets and sets sessions 5 | # without a locking strategy. 6 | class Null < Aws::SessionStore::DynamoDB::Locking::Base 7 | # Retrieve session if it exists from the database by id. 8 | # Unpack the data once retrieved from the database. 9 | def get_session_data(env, sid) 10 | handle_error(env) do 11 | result = @config.dynamo_db_client.get_item(get_session_opts(sid)) 12 | extract_data(env, result) 13 | end 14 | end 15 | 16 | # @return [Hash] Options for getting session. 17 | def get_session_opts(sid) 18 | merge_all(table_opts(sid), attr_opts) 19 | end 20 | 21 | # @return [String] Session data. 22 | def extract_data(env, result = nil) 23 | env['rack.initial_data'] = result[:item]['data'] if result[:item] 24 | unpack_data(result[:item]['data']) if result[:item] 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /aws-sessionstore-dynamodb.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | version = File.read(File.expand_path('VERSION', __dir__)).strip 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = 'aws-sessionstore-dynamodb' 7 | spec.version = version 8 | spec.author = 'Amazon Web Services' 9 | spec.email = ['aws-dr-rubygems@amazon.com'] 10 | spec.summary = 'Amazon DynamoDB Session Store for Rack web applications.' 11 | spec.description = 'The Amazon DynamoDB Session Store handles sessions ' \ 12 | 'for Rack web applications using a DynamoDB backend.' 13 | spec.homepage = 'https://github.com/aws/aws-sessionstore-dynamodb-ruby' 14 | spec.license = 'Apache-2.0' 15 | spec.files = Dir['LICENSE', 'CHANGELOG.md', 'VERSION', 'lib/**/*'] 16 | 17 | spec.add_dependency 'rack', '~> 3' 18 | spec.add_dependency 'rack-session', '~> 2' 19 | 20 | # Require 1.85.0 for user_agent_frameworks config 21 | spec.add_dependency 'aws-sdk-dynamodb', '~> 1', '>= 1.85.0' 22 | 23 | spec.required_ruby_version = '>= 2.7' 24 | end 25 | -------------------------------------------------------------------------------- /spec/aws/session_store/dynamo_db/table_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | module Aws::SessionStore::DynamoDB 6 | describe Table, integration: true do 7 | let(:table_name) { "sessionstore-integration-test-#{Time.now.to_i}" } 8 | let(:options) { { table_name: table_name } } 9 | let(:logger) { Logger.new(IO::NULL) } 10 | 11 | before { allow(Table).to receive(:logger).and_return(logger) } 12 | 13 | it 'Creates and deletes a new table' do 14 | expect(logger).to receive(:info) 15 | .with("Table #{table_name} created, waiting for activation...") 16 | expect(logger).to receive(:info) 17 | .with("Table #{table_name} is now ready to use.") 18 | Table.create_table(options) 19 | 20 | # second attempt should warn 21 | expect(logger).to receive(:warn) 22 | .with("Table #{table_name} already exists, skipping creation.") 23 | Table.create_table(options) 24 | 25 | expect(logger).to receive(:info) 26 | .with("Table #{table_name} deleted.") 27 | Table.delete_table(options) 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/aws/session_store/dynamo_db/errors/base_handler.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Aws::SessionStore::DynamoDB::Errors 4 | # BaseErrorHandler provides an interface for error handlers 5 | # that can be passed in to {Aws::SessionStore::DynamoDB::RackMiddleware}. 6 | # Each error handler must implement a handle_error method. 7 | # 8 | # @example Sample ErrorHandler class 9 | # class MyErrorHandler < BaseHandler 10 | # # Handles error passed in 11 | # def handle_error(e, env = {}) 12 | # File.open(path_to_file, 'w') {|f| f.write(e.message) } 13 | # false 14 | # end 15 | # end 16 | class BaseHandler 17 | # An error and an environment (optionally) will be passed in to 18 | # this method and it will determine how to deal 19 | # with the error. 20 | # Must return false if you have handled the error but are not re-raising the 21 | # error up the stack. 22 | # You may reraise the error passed. 23 | # 24 | # @param [Aws::DynamoDB::Errors::Base] error error passed in from 25 | # Aws::SessionStore::DynamoDB::RackMiddleware. 26 | # @param [Rack::Request::Environment,nil] env Rack environment 27 | # @return [false] If exception was handled and will not reraise exception. 28 | # @raise [Aws::DynamoDB::Errors] If error has be re-raised. 29 | def handle_error(error, env = {}) 30 | raise NotImplementedError 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | pull_request: 9 | branches: 10 | - main 11 | 12 | env: 13 | ruby_version: 3.4 14 | 15 | jobs: 16 | test: 17 | runs-on: ubuntu-latest 18 | strategy: 19 | fail-fast: false 20 | matrix: 21 | ruby: [2.7, '3.0', 3.1, 3.2, 3.3, 3.4, jruby-9.4, jruby-10.0] 22 | services: 23 | dynamodb: 24 | image: amazon/dynamodb-local:latest 25 | ports: 26 | - 8000:8000 27 | env: 28 | AWS_REGION: us-east-1 29 | AWS_ACCESS_KEY_ID: akid 30 | AWS_SECRET_ACCESS_KEY: secret 31 | AWS_ENDPOINT_URL_DYNAMODB: http://localhost:8000 32 | 33 | steps: 34 | - uses: actions/checkout@v4 35 | 36 | - name: Setup Ruby 37 | uses: ruby/setup-ruby@v1 38 | with: 39 | ruby-version: ${{ matrix.ruby }} 40 | 41 | - name: Install gems 42 | run: | 43 | bundle config set --local with 'test' 44 | bundle install 45 | 46 | - name: Tests 47 | run: bundle exec rake test 48 | 49 | rubocop: 50 | runs-on: ubuntu-latest 51 | 52 | steps: 53 | - uses: actions/checkout@v4 54 | 55 | - name: Setup Ruby 56 | uses: ruby/setup-ruby@v1 57 | with: 58 | ruby-version: ${{ env.ruby_version }} 59 | 60 | - name: Install gems 61 | run: | 62 | bundle config set --local with 'development' 63 | bundle install 64 | 65 | - name: Rubocop 66 | run: bundle exec rake rubocop 67 | -------------------------------------------------------------------------------- /lib/aws/session_store/dynamo_db/errors/default_handler.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Aws::SessionStore::DynamoDB::Errors 4 | # This class handles errors raised from DynamoDB. 5 | class DefaultHandler < Aws::SessionStore::DynamoDB::Errors::BaseHandler 6 | # Array of errors that will always be passed up the Rack stack. 7 | HARD_ERRORS = [ 8 | Aws::DynamoDB::Errors::ResourceNotFoundException, 9 | Aws::DynamoDB::Errors::ConditionalCheckFailedException, 10 | Aws::SessionStore::DynamoDB::Errors::MissingSecretKeyError, 11 | Aws::SessionStore::DynamoDB::Errors::LockWaitTimeoutError 12 | ].freeze 13 | 14 | # Determines behavior of DefaultErrorHandler 15 | # @param [true] raise_errors Pass all errors up the Rack stack. 16 | def initialize(raise_errors) 17 | super() 18 | @raise_errors = raise_errors 19 | end 20 | 21 | # Raises {HARD_ERRORS} up the Rack stack. 22 | # Places all other errors in Racks error stream. 23 | def handle_error(error, env = {}) 24 | raise error if HARD_ERRORS.include?(error.class) || @raise_errors 25 | 26 | store_error(error, env) 27 | false 28 | end 29 | 30 | # Sends error to error stream 31 | def store_error(error, env = {}) 32 | env['rack.errors'].puts(errors_string(error)) if env 33 | end 34 | 35 | # Returns string to be placed in error stream 36 | def errors_string(error) 37 | str = [] 38 | str << "Exception occurred: #{error.message}" 39 | str << 'Stack trace:' 40 | str += error.backtrace.map { |l| " #{l}" } 41 | str.join("\n") 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🚀 Feature Request 3 | description: Suggest an idea for this project 4 | title: "(short issue description)" 5 | labels: [feature-request, needs-triage] 6 | assignees: [] 7 | body: 8 | - type: textarea 9 | id: description 10 | attributes: 11 | label: Describe the feature 12 | description: A clear and concise description of the feature you are proposing. 13 | validations: 14 | required: true 15 | - type: textarea 16 | id: use-case 17 | attributes: 18 | label: Use Case 19 | description: | 20 | Why do you need this feature? For example: "I'm always frustrated when..." 21 | validations: 22 | required: true 23 | - type: textarea 24 | id: solution 25 | attributes: 26 | label: Proposed Solution 27 | description: | 28 | Suggest how to implement the addition or change. Please include prototype/workaround/sketch/reference implementation. 29 | validations: 30 | required: false 31 | - type: textarea 32 | id: other 33 | attributes: 34 | label: Other Information 35 | description: | 36 | Any alternative solutions or features you considered, a more detailed explanation, stack traces, related issues, links for context, etc. 37 | validations: 38 | required: false 39 | - type: checkboxes 40 | id: ack 41 | attributes: 42 | label: Acknowledgements 43 | options: 44 | - label: I may be able to implement this feature request 45 | required: false 46 | - label: This feature might incur a breaking change 47 | required: false 48 | - type: input 49 | id: version 50 | attributes: 51 | label: version of the aws-sessionstore-dynamodb-ruby Gem 52 | validations: 53 | required: true 54 | - type: input 55 | id: environment 56 | attributes: 57 | label: Environment details (OS name and version, etc.) 58 | validations: 59 | required: true 60 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | Unreleased Changes 2 | ------------------ 3 | 4 | 3.0.1 (2024-11-16) 5 | ------------------ 6 | 7 | * Issue - `Configuration` now takes environment variables with precedence over YAML configuration. 8 | 9 | * Issue - Use ENV variables that are prefixed by `AWS_`. 10 | 11 | 3.0.0 (2024-10-29) 12 | ------------------ 13 | 14 | * Feature - Uses `rack ~> 3` as the minimum. 15 | 16 | * Feature - Drop support for Ruby 2.5 and 2.6. 17 | 18 | * Feature - Support additional configuration options through ENV. 19 | 20 | * Feature - Moves error classes into the `Errors` module. 21 | 22 | * Issue - Set `RackMiddleware`'s `#find_session`, `#write_session`, and `#delete_session` as public. 23 | 24 | * Issue - Validate `Configuration` has a secret key on `RackMiddleware#initialize` instead of on `#find_session`. 25 | 26 | 2.2.0 (2024-01-25) 27 | ------------------ 28 | 29 | * Feature - Drop support for Ruby 2.3 and 2.4. 30 | 31 | * Issue - Relax `rack` dependency to allow version 3. Adds `rack-session` to the gemspec. 32 | 33 | 2.1.0 (2023-06-02) 34 | ------------------ 35 | 36 | * Feature - Improve User-Agent tracking and bump minimum DynamoDB version. 37 | 38 | 2.0.1 (2020-11-16) 39 | ------------------ 40 | 41 | * Issue - Expose `:config` in `RackMiddleware` and `:config_file` in `Configuration`. 42 | 43 | * Issue - V2 of this release was still loading SDK V1 credential keys. This removes support for client options specified in YAML configuration (behavior change). Instead, construct `Aws::DynamoDB::Client` and use the `dynamo_db_client` option. 44 | 45 | 2.0.0 (2020-11-11) 46 | ------------------ 47 | 48 | * Remove Rails support (moved to the `aws-sdk-rails` gem). 49 | 50 | * Use V3 of Ruby SDK 51 | 52 | * Fix a `dynamo_db.scan()` incompatibility from the V1 -> V2 upgrade in the garbage collector. 53 | 54 | 1.0.0 (2017-08-14) 55 | ------------------ 56 | 57 | * Use V2 of Ruby SDK (no history) 58 | 59 | 60 | 0.5.1 (2015-08-26) 61 | ------------------ 62 | 63 | * Bug Fix (no history) 64 | 65 | 0.5.0 (2013-08-27) 66 | ------------------ 67 | 68 | * Initial Release (no history) 69 | -------------------------------------------------------------------------------- /.github/workflows/stale_issues.yml: -------------------------------------------------------------------------------- 1 | name: "Close stale issues" 2 | 3 | # Controls when the action will run. 4 | on: 5 | schedule: 6 | - cron: "0 0 * * *" 7 | 8 | permissions: {} 9 | jobs: 10 | cleanup: 11 | permissions: 12 | issues: write # to label, comment and close issues 13 | pull-requests: write # to label, comment and close pull requests 14 | 15 | runs-on: ubuntu-latest 16 | name: Stale issue job 17 | steps: 18 | - uses: aws-actions/stale-issue-cleanup@v6 19 | with: 20 | # Setting messages to an empty string will cause the automation to skip 21 | # that category 22 | ancient-issue-message: Greetings! We’re closing this issue because it has been open a long time and hasn’t been updated in a while and may not be getting the attention it deserves. We encourage you to check if this is still an issue in the latest release and if you find that this is still a problem, please feel free to comment or open a new issue. 23 | stale-issue-message: This issue has not received a response in 1 week. If you still think there is a problem, please leave a comment to avoid the issue from automatically closing. 24 | stale-pr-message: This PR has not received a response in 1 week. If you still think there is a problem, please leave a comment to avoid the PR from automatically closing. 25 | # These labels are required 26 | stale-issue-label: closing-soon 27 | exempt-issue-label: no-autoclose 28 | stale-pr-label: closing-soon 29 | exempt-pr-label: pr/needs-review 30 | response-requested-label: response-requested 31 | 32 | # Don't set closed-for-staleness label to skip closing very old issues 33 | # regardless of label 34 | closed-for-staleness-label: closed-for-staleness 35 | 36 | # Issue timing 37 | days-before-stale: 10 38 | days-before-close: 4 39 | days-before-ancient: 36500 40 | 41 | # If you don't want to mark a issue as being ancient based on a 42 | # threshold of "upvotes", you can set this here. An "upvote" is 43 | # the total number of +1, heart, hooray, and rocket reactions 44 | # on an issue. 45 | minimum-upvotes-to-exempt: 10 46 | 47 | repo-token: ${{ secrets.GITHUB_TOKEN }} 48 | # loglevel: DEBUG 49 | # Set dry-run to true to not perform label or close actions. 50 | # dry-run: true 51 | -------------------------------------------------------------------------------- /spec/aws/session_store/dynamo_db/locking/threaded_sessions_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe Aws::SessionStore::DynamoDB::RackMiddleware, integration: true do 6 | include Rack::Test::Methods 7 | 8 | def thread(mul_val, time, check) 9 | Thread.new do 10 | sleep(time) 11 | get '/' 12 | expect(last_request.session[:multiplier]).to eq(mul_val) if check 13 | end 14 | end 15 | 16 | def thread_exception(error) 17 | Thread.new { expect { get '/' }.to raise_error(error) } 18 | end 19 | 20 | def update_item_mock(options, update_method) 21 | sleep(0.50) if options[:return_values] == 'UPDATED_NEW' && options.key?(:expected) 22 | update_method.call(options) 23 | end 24 | 25 | let(:base_app) { MultiplierApplication.new } 26 | let(:app) { Aws::SessionStore::DynamoDB::RackMiddleware.new(base_app, @options) } 27 | 28 | before do 29 | @options = Aws::SessionStore::DynamoDB::Configuration.new.to_hash 30 | @options[:table_name] = 'sessionstore-integration-test' 31 | @options[:enable_locking] = true 32 | @options[:secret_key] = 'watermelon_smiles' 33 | 34 | update_method = @options[:dynamo_db_client].method(:update_item) 35 | expect(@options[:dynamo_db_client]).to receive(:update_item).at_least(:once) do |options| 36 | update_item_mock(options, update_method) 37 | end 38 | end 39 | 40 | it 'should wait for lock' do 41 | @options[:lock_expiry_time] = 2000 42 | 43 | get '/' 44 | expect(last_request.session[:multiplier]).to eq(1) 45 | 46 | t1 = thread(2, 0, false) 47 | t2 = thread(4, 0.25, true) 48 | t1.join 49 | t2.join 50 | end 51 | 52 | it 'should bust lock' do 53 | @options[:lock_expiry_time] = 100 54 | 55 | get '/' 56 | expect(last_request.session[:multiplier]).to eq(1) 57 | 58 | t1 = thread_exception(Aws::DynamoDB::Errors::ConditionalCheckFailedException) 59 | t2 = thread(2, 0.25, true) 60 | t1.join 61 | t2.join 62 | end 63 | 64 | it 'should throw exceeded time spent aquiring lock error' do 65 | @options[:lock_expiry_time] = 1000 66 | @options[:lock_retry_delay] = 100 67 | @options[:lock_max_wait_time] = 0.25 68 | 69 | get '/' 70 | expect(last_request.session[:multiplier]).to eq(1) 71 | 72 | t1 = thread(2, 0, false) 73 | sleep(0.25) 74 | t2 = thread_exception(Aws::SessionStore::DynamoDB::Errors::LockWaitTimeoutError) 75 | t1.join 76 | t2.join 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /spec/aws/session_store/dynamo_db/error/default_handler_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe Aws::SessionStore::DynamoDB do 6 | include Rack::Test::Methods 7 | 8 | let(:missing_key_error) { Aws::SessionStore::DynamoDB::Errors::MissingSecretKeyError } 9 | let(:resource_error_msg) { 'The Resource is not found' } 10 | let(:resource_error) do 11 | Aws::DynamoDB::Errors::ResourceNotFoundException.new(double('Seahorse::Client::RequestContext'), resource_error_msg) 12 | end 13 | let(:key_error_msg) { 'The provided key element does not match the schema' } 14 | let(:key_error) do 15 | Aws::DynamoDB::Errors::ValidationException.new(double('Seahorse::Client::RequestContext'), key_error_msg) 16 | end 17 | let(:client_error_msg) { 'Unrecognized Client' } 18 | let(:client_error) do 19 | Aws::DynamoDB::Errors::UnrecognizedClientException.new(double('Seahorse::Client::RequestContext'), client_error_msg) 20 | end 21 | 22 | let(:options) do 23 | { dynamo_db_client: client, secret_key: 'meltingbutter' } 24 | end 25 | 26 | let(:base_app) { MultiplierApplication.new } 27 | let(:app) { Aws::SessionStore::DynamoDB::RackMiddleware.new(base_app, options) } 28 | let(:client) { Aws::DynamoDB::Client.new(stub_responses: true) } 29 | 30 | it 'raises error for missing secret key' do 31 | allow(client).to receive(:update_item).and_raise(missing_key_error) 32 | expect { get '/' }.to raise_error(missing_key_error) 33 | end 34 | 35 | it 'catches exception for inaccurate table name and raises error ' do 36 | allow(client).to receive(:update_item).and_raise(resource_error) 37 | expect { get '/' }.to raise_error(resource_error) 38 | end 39 | 40 | it 'catches exception for inaccurate table key' do 41 | allow(client).to receive(:update_item).and_raise(key_error) 42 | allow(client).to receive(:get_item).and_raise(key_error) 43 | 44 | get '/' 45 | expect(last_request.env['rack.errors'].string).to include(key_error_msg) 46 | end 47 | 48 | context 'raise_error is true' do 49 | before do 50 | options[:raise_errors] = true 51 | end 52 | 53 | it 'raises all errors' do 54 | allow(client).to receive(:update_item).and_raise(client_error) 55 | expect { get '/' }.to raise_error(client_error) 56 | end 57 | 58 | it 'catches exceptions for inaccurate table key and raises error' do 59 | allow(client).to receive(:update_item).and_raise(key_error) 60 | expect { get '/' }.to raise_error(key_error) 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: "🐛 Bug Report" 3 | description: Report a bug 4 | title: "(short issue description)" 5 | labels: [bug, needs-triage] 6 | assignees: [] 7 | body: 8 | - type: textarea 9 | id: description 10 | attributes: 11 | label: Describe the bug 12 | description: What is the problem? A clear and concise description of the bug. 13 | validations: 14 | required: true 15 | - type: textarea 16 | id: expected 17 | attributes: 18 | label: Expected Behavior 19 | description: | 20 | What did you expect to happen? 21 | validations: 22 | required: true 23 | - type: textarea 24 | id: current 25 | attributes: 26 | label: Current Behavior 27 | description: | 28 | What actually happened? 29 | 30 | Please include full errors, uncaught exceptions, stack traces, and relevant logs. 31 | If service responses are relevant, please include wire logs. 32 | validations: 33 | required: true 34 | - type: textarea 35 | id: reproduction 36 | attributes: 37 | label: Reproduction Steps 38 | description: | 39 | Provide a self-contained, concise snippet of code that can be used to reproduce the issue. 40 | For more complex issues provide a repo with the smallest sample that reproduces the bug. 41 | 42 | Avoid including business logic or unrelated code, it makes diagnosis more difficult. 43 | The code sample should be an SSCCE. See http://sscce.org/ for details. In short, please provide a code sample that we can copy/paste, run and reproduce. 44 | validations: 45 | required: true 46 | - type: textarea 47 | id: solution 48 | attributes: 49 | label: Possible Solution 50 | description: | 51 | Suggest a fix/reason for the bug 52 | validations: 53 | required: false 54 | - type: textarea 55 | id: context 56 | attributes: 57 | label: Additional Information/Context 58 | description: | 59 | Anything else that might be relevant for troubleshooting this bug. Providing context helps us come up with a solution that is most useful in the real world. 60 | validations: 61 | required: false 62 | - type: input 63 | id: aws-sessionstore-dynamodb-ruby version 64 | attributes: 65 | label: Gem version used 66 | validations: 67 | required: true 68 | - type: input 69 | id: environment 70 | attributes: 71 | label: Environment details (Version of Ruby, OS environment) 72 | validations: 73 | required: true 74 | -------------------------------------------------------------------------------- /lib/aws/session_store/dynamo_db/table.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'aws-sdk-dynamodb' 4 | require 'logger' 5 | 6 | module Aws::SessionStore::DynamoDB 7 | # This module provides a way to create and delete a session table. 8 | module Table 9 | class << self 10 | # Creates a session table. 11 | # @option (see Configuration#initialize) 12 | def create_table(options = {}) 13 | config = load_config(options) 14 | config.dynamo_db_client.create_table(create_opts(config)) 15 | logger.info "Table #{config.table_name} created, waiting for activation..." 16 | config.dynamo_db_client.wait_until(:table_exists, table_name: config.table_name) 17 | logger.info "Table #{config.table_name} is now ready to use." 18 | rescue Aws::DynamoDB::Errors::ResourceInUseException 19 | logger.warn "Table #{config.table_name} already exists, skipping creation." 20 | end 21 | 22 | # Deletes a session table. 23 | # @option (see Configuration#initialize) 24 | def delete_table(options = {}) 25 | config = load_config(options) 26 | config.dynamo_db_client.delete_table(table_name: config.table_name) 27 | config.dynamo_db_client.wait_until(:table_not_exists, table_name: config.table_name) 28 | logger.info "Table #{config.table_name} deleted." 29 | end 30 | 31 | private 32 | 33 | def logger 34 | @logger ||= Logger.new($stdout) 35 | end 36 | 37 | # Loads configuration options. 38 | # @option (see Configuration#initialize) 39 | def load_config(options = {}) 40 | Aws::SessionStore::DynamoDB::Configuration.new(options) 41 | end 42 | 43 | def create_opts(config) 44 | properties(config.table_name, config.table_key).merge( 45 | throughput(config.read_capacity, config.write_capacity) 46 | ) 47 | end 48 | 49 | # @return Properties for the session table. 50 | def properties(table_name, hash_key) 51 | attributes(hash_key).merge(schema(table_name, hash_key)) 52 | end 53 | 54 | # @return [Hash] Attribute settings for creating the session table. 55 | def attributes(hash_key) 56 | { 57 | attribute_definitions: [ 58 | { attribute_name: hash_key, attribute_type: 'S' } 59 | ] 60 | } 61 | end 62 | 63 | # @return Schema values for the session table. 64 | def schema(table_name, hash_key) 65 | { 66 | table_name: table_name, 67 | key_schema: [{ attribute_name: hash_key, key_type: 'HASH' }] 68 | } 69 | end 70 | 71 | # @return Throughput for the session table. 72 | def throughput(read, write) 73 | { 74 | provisioned_throughput: { 75 | read_capacity_units: read, 76 | write_capacity_units: write 77 | } 78 | } 79 | end 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | Thank you for your interest in contributing to our project. Whether it's a bug report, new feature, correction, or additional 4 | documentation, we greatly value feedback and contributions from our community. 5 | 6 | Please read through this document before submitting any issues or pull requests to ensure we have all the necessary 7 | information to effectively respond to your bug report or contribution. 8 | 9 | 10 | ## Reporting Bugs/Feature Requests 11 | 12 | We welcome you to use the GitHub issue tracker to report bugs or suggest features. 13 | 14 | When filing an issue, please check existing open, or recently closed, issues to make sure somebody else hasn't already 15 | reported the issue. Please try to include as much information as you can. Details like these are incredibly useful: 16 | 17 | * A reproducible test case or series of steps 18 | * The version of our code being used 19 | * Any modifications you've made relevant to the bug 20 | * Anything unusual about your environment or deployment 21 | 22 | 23 | ## Contributing via Pull Requests 24 | Contributions via pull requests are much appreciated. Before sending us a pull request, please ensure that: 25 | 26 | 1. You are working against the latest source on the *main* branch. 27 | 2. You check existing open, and recently merged, pull requests to make sure someone else hasn't addressed the problem already. 28 | 3. You open an issue to discuss any significant work - we would hate for your time to be wasted. 29 | 30 | To send us a pull request, please: 31 | 32 | 1. Fork the repository. 33 | 2. Modify the source; please focus on the specific change you are contributing. If you also reformat all the code, it will be hard for us to focus on your change. 34 | 3. Ensure local tests pass. 35 | 4. Commit to your fork using clear commit messages. 36 | 5. Send us a pull request, answering any default questions in the pull request interface. 37 | 6. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation. 38 | 39 | GitHub provides additional document on [forking a repository](https://help.github.com/articles/fork-a-repo/) and 40 | [creating a pull request](https://help.github.com/articles/creating-a-pull-request/). 41 | 42 | 43 | ## Finding contributions to work on 44 | Looking at the existing issues is a great way to find something to contribute on. As our projects, by default, use the default GitHub issue labels (enhancement/bug/duplicate/help wanted/invalid/question/wontfix), looking at any 'help wanted' issues is a great place to start. 45 | 46 | 47 | ## Code of Conduct 48 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 49 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 50 | opensource-codeofconduct@amazon.com with any additional questions or comments. 51 | 52 | 53 | ## Security issue notifications 54 | If you discover a potential security issue in this project we ask that you notify AWS/Amazon Security via our [vulnerability reporting page](http://aws.amazon.com/security/vulnerability-reporting/). Please do **not** create a public github issue. 55 | 56 | 57 | ## Licensing 58 | 59 | See the [LICENSE](LICENSE) file for our project's licensing. We will ask you to confirm the licensing of your contribution. 60 | -------------------------------------------------------------------------------- /spec/aws/session_store/dynamo_db/garbage_collection_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe Aws::SessionStore::DynamoDB::GarbageCollection do 6 | def items(min, max) 7 | (min..max).map do |i| 8 | { 'session_id' => { s: i.to_s } } 9 | end 10 | end 11 | 12 | def format_scan_result 13 | items = (31..49).map do |i| 14 | { 'session_id' => { s: i.to_s } } 15 | end 16 | 17 | items.each_with_object([]) do |item, rqst_array| 18 | rqst_array << { delete_request: { key: item } } 19 | end 20 | end 21 | 22 | def collect_garbage 23 | options = { dynamo_db_client: dynamo_db_client, max_age: 100, max_stale: 100 } 24 | Aws::SessionStore::DynamoDB::GarbageCollection.collect_garbage(options) 25 | end 26 | 27 | let(:scan_resp1) do 28 | { 29 | items: items(0, 49), 30 | count: 50, 31 | scanned_count: 1000, 32 | last_evaluated_key: {} 33 | } 34 | end 35 | 36 | let(:scan_resp2) do 37 | { 38 | items: items(0, 31), 39 | last_evaluated_key: { 'session_id' => { s: '31' } } 40 | } 41 | end 42 | 43 | let(:scan_resp3) do 44 | { items: items(31, 49), last_evaluated_key: {} } 45 | end 46 | 47 | let(:write_resp1) do 48 | { unprocessed_items: {} } 49 | end 50 | 51 | let(:write_resp2) do 52 | { 53 | unprocessed_items: { 54 | 'sessions' => [ 55 | { delete_request: { key: { 'session_id' => { s: '1' } } } }, 56 | { delete_request: { key: { 'session_id' => { s: '17' } } } } 57 | ] 58 | } 59 | } 60 | end 61 | 62 | let(:dynamo_db_client) { Aws::DynamoDB::Client.new(stub_responses: true) } 63 | 64 | it 'processes scan results greater than 25 and deletes in batches of 25' do 65 | expect(dynamo_db_client).to receive(:scan) 66 | .exactly(1).times.and_return(scan_resp1) 67 | expect(dynamo_db_client).to receive(:batch_write_item) 68 | .exactly(2).times.and_return(write_resp1) 69 | collect_garbage 70 | end 71 | 72 | it 'gets scan results then returns last evaluated key and resumes scanning' do 73 | expect(dynamo_db_client).to receive(:scan) 74 | .exactly(1).times.and_return(scan_resp2) 75 | expect(dynamo_db_client).to receive(:scan) 76 | .exactly(1).times.with( 77 | hash_including(exclusive_start_key: scan_resp2[:last_evaluated_key]) 78 | ) 79 | .and_return(scan_resp3) 80 | expect(dynamo_db_client).to receive(:batch_write_item) 81 | .exactly(3).times.and_return(write_resp1) 82 | collect_garbage 83 | end 84 | 85 | it 'it formats unprocessed_items and then batch deletes them' do 86 | expect(dynamo_db_client).to receive(:scan) 87 | .exactly(1).times.and_return(scan_resp3) 88 | expect(dynamo_db_client).to receive(:batch_write_item) 89 | .ordered 90 | .with({ request_items: { 'sessions' => format_scan_result } }) 91 | .and_return(write_resp2) 92 | expect(dynamo_db_client).to receive(:batch_write_item) 93 | .ordered 94 | .with( 95 | { 96 | request_items: { 97 | 'sessions' => [ 98 | { delete_request: { key: { 'session_id' => { s: '1' } } } }, 99 | { delete_request: { key: { 'session_id' => { s: '17' } } } } 100 | ] 101 | } 102 | } 103 | ) 104 | .and_return(write_resp1) 105 | collect_garbage 106 | end 107 | end 108 | -------------------------------------------------------------------------------- /spec/aws/session_store/dynamo_db/rack_middleware_integration_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | module Aws 6 | module SessionStore 7 | module DynamoDB 8 | describe RackMiddleware, integration: true do 9 | include Rack::Test::Methods 10 | 11 | def table_opts(sid) 12 | { 13 | table_name: config.table_name, 14 | key: { config.table_key => sid } 15 | } 16 | end 17 | 18 | def attr_opts 19 | { 20 | attributes_to_get: %w[data created_at], 21 | consistent_read: true 22 | } 23 | end 24 | 25 | def extract_time(sid) 26 | options = table_opts(sid).merge(attr_opts) 27 | Time.at(config.dynamo_db_client.get_item(options)[:item]['created_at'].to_f) 28 | end 29 | 30 | let(:options) do 31 | { table_name: 'sessionstore-integration-test', secret_key: 'watermelon_cherries' } 32 | end 33 | 34 | let(:base_app) { MultiplierApplication.new } 35 | let(:app) { RackMiddleware.new(base_app, options) } 36 | let(:config) { app.config } 37 | 38 | it 'stores session data in session object' do 39 | get '/' 40 | expect(last_request.session[:multiplier]).to eq(1) 41 | end 42 | 43 | it 'creates a new HTTP cookie when Cookie not supplied' do 44 | get '/' 45 | expect(last_response.body).to eq('All good!') 46 | expect(last_response['Set-Cookie']).to be_truthy 47 | end 48 | 49 | it 'does not rewrite Cookie if cookie previously/accuarately set' do 50 | get '/' 51 | expect(last_response['Set-Cookie']).not_to be_nil 52 | 53 | get '/' 54 | expect(last_response['Set-Cookie']).to be_nil 55 | end 56 | 57 | it 'does not set cookie when defer option is specified' do 58 | options[:defer] = true 59 | get '/' 60 | expect(last_response['Set-Cookie']).to be_nil 61 | end 62 | 63 | it 'creates new session with false/nonexistant http-cookie id' do 64 | env = { 65 | 'HTTP_COOKIE' => 'rack.session=ApplePieBlueberries', 66 | 'rack.session' => { 'multiplier' => 1 } 67 | } 68 | get '/', {}, env 69 | expect(last_response['Set-Cookie']).not_to eq('rack.session=ApplePieBlueberries') 70 | expect(last_response['Set-Cookie']).not_to be_nil 71 | end 72 | 73 | it 'expires after specified time and sets date for cookie to expire' do 74 | options[:expire_after] = 1 75 | get '/' 76 | session_cookie = last_response['Set-Cookie'] 77 | sleep(1.2) 78 | 79 | get '/' 80 | expect(last_response['Set-Cookie']).not_to be_nil 81 | expect(last_response['Set-Cookie']).not_to eq(session_cookie) 82 | end 83 | 84 | it 'will not set a session cookie when defer is true' do 85 | options[:defer] = true 86 | get '/' 87 | expect(last_response['Set-Cookie']).to be_nil 88 | end 89 | 90 | it 'adds the created at attribute for a new session' do 91 | get '/' 92 | expect(last_request.env['dynamo_db.new_session']).to eq('true') 93 | sid = last_response['Set-Cookie'].split(/[;=]/)[1] 94 | time = extract_time(sid) 95 | expect(time).to be_within(2).of(Time.now) 96 | 97 | get '/' 98 | expect(last_request.env['dynamo_db.new_session']).to be_nil 99 | end 100 | end 101 | end 102 | end 103 | end 104 | -------------------------------------------------------------------------------- /lib/aws/session_store/dynamo_db/garbage_collection.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'aws-sdk-dynamodb' 4 | 5 | module Aws::SessionStore::DynamoDB 6 | # Collects and deletes unwanted sessions based on 7 | # their creation and update dates. 8 | module GarbageCollection 9 | class << self 10 | # Scans DynamoDB session table to find sessions that match the max age and 11 | # max stale period requirements. it then deletes all of the found sessions. 12 | # @option (see Configuration#initialize) 13 | def collect_garbage(options = {}) 14 | config = load_config(options) 15 | last_key = eliminate_unwanted_sessions(config) 16 | last_key = eliminate_unwanted_sessions(config, last_key) until last_key.empty? 17 | end 18 | 19 | private 20 | 21 | # Loads configuration options. 22 | # @option (see Configuration#initialize) 23 | def load_config(options = {}) 24 | Aws::SessionStore::DynamoDB::Configuration.new(options) 25 | end 26 | 27 | # Sets scan filter attributes based on attributes specified. 28 | def scan_filter(config) 29 | hash = {} 30 | hash['created_at'] = oldest_date(config.max_age) if config.max_age 31 | hash['updated_at'] = oldest_date(config.max_stale) if config.max_stale 32 | { scan_filter: hash } 33 | end 34 | 35 | # Scans and deletes batch. 36 | def eliminate_unwanted_sessions(config, last_key = nil) 37 | scan_result = scan(config, last_key) 38 | batch_delete(config, scan_result[:items]) 39 | scan_result[:last_evaluated_key] || {} 40 | end 41 | 42 | # Scans the table for sessions matching the max age and 43 | # max stale time specified. 44 | def scan(config, last_item = nil) 45 | options = scan_opts(config) 46 | options = options.merge(start_key(last_item)) if last_item 47 | config.dynamo_db_client.scan(options) 48 | end 49 | 50 | # Deletes the batch gotten from the scan result. 51 | def batch_delete(config, items) 52 | loop do 53 | subset = items.shift(25) 54 | sub_batch = write(subset) 55 | process!(config, sub_batch) 56 | break if subset.empty? 57 | end 58 | end 59 | 60 | # Turns array into correct format to be passed in to 61 | # a delete request. 62 | def write(sub_batch) 63 | sub_batch.each_with_object([]) do |item, rqst_array| 64 | rqst_array << { delete_request: { key: item } } 65 | end 66 | end 67 | 68 | # Processes pending request items. 69 | def process!(config, sub_batch) 70 | return if sub_batch.empty? 71 | 72 | opts = { request_items: { config.table_name => sub_batch } } 73 | loop do 74 | response = config.dynamo_db_client.batch_write_item(opts) 75 | opts[:request_items] = response[:unprocessed_items] 76 | break if opts[:request_items].empty? 77 | end 78 | end 79 | 80 | # Provides scan options. 81 | def scan_opts(config) 82 | table_opts(config).merge(scan_filter(config)) 83 | end 84 | 85 | # Provides table options 86 | def table_opts(config) 87 | { 88 | table_name: config.table_name, 89 | attributes_to_get: [config.table_key] 90 | } 91 | end 92 | 93 | # Provides specified date attributes. 94 | def oldest_date(sec) 95 | { 96 | attribute_value_list: [n: (Time.now - sec).to_f.to_s], 97 | comparison_operator: 'LT' 98 | } 99 | end 100 | 101 | # Provides start key. 102 | def start_key(last_item) 103 | { exclusive_start_key: last_item } 104 | end 105 | end 106 | end 107 | end 108 | -------------------------------------------------------------------------------- /lib/aws/session_store/dynamo_db/rack_middleware.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rack/session/abstract/id' 4 | require 'openssl' 5 | require 'aws-sdk-dynamodb' 6 | 7 | module Aws::SessionStore::DynamoDB 8 | # This class is an ID based Session Store Rack Middleware 9 | # that uses a DynamoDB backend for session storage. 10 | class RackMiddleware < Rack::Session::Abstract::Persisted 11 | # Initializes SessionStore middleware. 12 | # 13 | # @param app Rack application. 14 | # @option (see Configuration#initialize) 15 | # @raise [Aws::DynamoDB::Errors::ResourceNotFoundException] If a valid table name is not provided. 16 | # @raise [Aws::SessionStore::DynamoDB::MissingSecretKey] If a secret key is not provided. 17 | def initialize(app, options = {}) 18 | super 19 | @config = Configuration.new(options) 20 | validate_config 21 | set_locking_strategy 22 | end 23 | 24 | # Get session from the database or create a new session. 25 | # 26 | # @raise [Aws::SessionStore::DynamoDB::Errors::LockWaitTimeoutError] If the session 27 | # has waited too long to obtain lock. 28 | def find_session(req, sid) 29 | case verify_hmac(sid) 30 | when nil 31 | set_new_session_properties(req.env) 32 | when false 33 | handle_error { raise Errors::InvalidIDError } 34 | set_new_session_properties(req.env) 35 | else 36 | data = @lock.get_session_data(req.env, sid) 37 | [sid, data || {}] 38 | end 39 | end 40 | 41 | # Sets the session in the database after packing data. 42 | # 43 | # @return [Hash] If session has been saved. 44 | # @return [false] If session has could not be saved. 45 | def write_session(req, sid, session, options) 46 | @lock.set_session_data(req.env, sid, session, options) 47 | end 48 | 49 | # Destroys session and removes session from database. 50 | # 51 | # @return [String] return a new session id or nil if options[:drop] 52 | def delete_session(req, sid, options) 53 | @lock.delete_session(req.env, sid) 54 | generate_sid unless options[:drop] 55 | end 56 | 57 | # @return [Configuration] An instance of Configuration that is used for 58 | # this middleware. 59 | attr_reader :config 60 | 61 | private 62 | 63 | def set_locking_strategy 64 | @lock = 65 | if @config.enable_locking 66 | Aws::SessionStore::DynamoDB::Locking::Pessimistic.new(@config) 67 | else 68 | Aws::SessionStore::DynamoDB::Locking::Null.new(@config) 69 | end 70 | end 71 | 72 | def validate_config 73 | raise Errors::MissingSecretKeyError unless @config.secret_key 74 | end 75 | 76 | # Sets new session properties. 77 | def set_new_session_properties(env) 78 | env['dynamo_db.new_session'] = 'true' 79 | [generate_sid, {}] 80 | end 81 | 82 | # Each database operation is placed in this rescue wrapper. 83 | # This wrapper will call the method, rescue any exceptions and then pass 84 | # exceptions to the configured session handler. 85 | def handle_error(env = nil) 86 | yield 87 | rescue Aws::DynamoDB::Errors::Base, 88 | Aws::SessionStore::DynamoDB::Errors::InvalidIDError => e 89 | @config.error_handler.handle_error(e, env) 90 | end 91 | 92 | # Generate HMAC hash based on MD5 93 | def generate_hmac(sid, secret) 94 | OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('MD5'), secret, sid).strip 95 | end 96 | 97 | # Generate sid with HMAC hash 98 | def generate_sid(secure = @sid_secure) 99 | sid = super 100 | "#{generate_hmac(sid, @config.secret_key)}--" + sid 101 | end 102 | 103 | # Verify digest of HMACed hash 104 | # 105 | # @return [true] If the HMAC id has been verified. 106 | # @return [false] If the HMAC id has been corrupted. 107 | def verify_hmac(sid) 108 | return unless sid 109 | 110 | digest, ver_sid = sid.split('--') 111 | return false unless ver_sid 112 | 113 | digest == generate_hmac(ver_sid, @config.secret_key) 114 | end 115 | end 116 | end 117 | -------------------------------------------------------------------------------- /spec/aws/session_store/dynamo_db/configuration_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'yaml' 5 | 6 | describe Aws::SessionStore::DynamoDB::Configuration do 7 | let(:defaults) do 8 | Aws::SessionStore::DynamoDB::Configuration::MEMBERS.merge( 9 | dynamo_db_client: kind_of(Aws::DynamoDB::Client), 10 | error_handler: kind_of(Aws::SessionStore::DynamoDB::Errors::DefaultHandler) 11 | ) 12 | end 13 | 14 | let(:options) do 15 | { 16 | table_name: 'SessionTable', 17 | table_key: 'SessionKey', 18 | consistent_read: false, 19 | read_capacity: 20, 20 | write_capacity: 10, 21 | raise_errors: true, 22 | max_age: 7 * 60, 23 | max_stale: 7, 24 | secret_key: 'SecretKey' 25 | } 26 | end 27 | 28 | def setup_env(options) 29 | options.each do |k, v| 30 | ENV["AWS_DYNAMO_DB_SESSION_#{k.to_s.upcase}"] = v.to_s 31 | end 32 | end 33 | 34 | def teardown_env(options) 35 | options.each_key { |k| ENV.delete("AWS_DYNAMO_DB_SESSION_#{k.to_s.upcase}") } 36 | end 37 | 38 | let(:client) { Aws::DynamoDB::Client.new(stub_responses: true) } 39 | 40 | before do 41 | allow(Aws::DynamoDB::Client).to receive(:new).and_return(client) 42 | end 43 | 44 | it 'configures defaults without runtime, ENV, or YAML options' do 45 | cfg = Aws::SessionStore::DynamoDB::Configuration.new 46 | expect(cfg.to_hash).to include(defaults) 47 | end 48 | 49 | it 'configures with YAML with precedence over defaults' do 50 | Tempfile.create('aws_dynamo_db_session_store.yml') do |f| 51 | f << options.transform_keys(&:to_s).to_yaml 52 | f.rewind 53 | cfg = Aws::SessionStore::DynamoDB::Configuration.new(config_file: f.path) 54 | expect(cfg.to_hash).to include(options) 55 | end 56 | end 57 | 58 | it 'configures with ENV with precedence over YAML' do 59 | setup_env(options) 60 | Tempfile.create('aws_dynamo_db_session_store.yml') do |f| 61 | f << { table_name: 'OldTable', table_key: 'OldKey' }.transform_keys(&:to_s).to_yaml 62 | f.rewind 63 | cfg = Aws::SessionStore::DynamoDB::Configuration.new(config_file: f.path) 64 | expect(cfg.to_hash).to include(options) 65 | ensure 66 | teardown_env(options) 67 | end 68 | end 69 | 70 | it 'configures in code with full precedence' do 71 | old = { table_name: 'OldTable', table_key: 'OldKey' } 72 | setup_env(options.merge(old)) 73 | Tempfile.create('aws_dynamo_db_session_store.yml') do |f| 74 | f << old.transform_keys(&:to_s).to_yaml 75 | f.rewind 76 | cfg = Aws::SessionStore::DynamoDB::Configuration.new(options.merge(config_file: f.path)) 77 | expect(cfg.to_hash).to include(options) 78 | ensure 79 | teardown_env(options.merge(old)) 80 | end 81 | end 82 | 83 | it 'allows for config file to be configured with ENV' do 84 | Tempfile.create('aws_dynamo_db_session_store.yml') do |f| 85 | f << options.transform_keys(&:to_s).to_yaml 86 | f.rewind 87 | ENV['AWS_DYNAMO_DB_SESSION_CONFIG_FILE'] = f.path 88 | cfg = Aws::SessionStore::DynamoDB::Configuration.new 89 | expect(cfg.to_hash).to include(options) 90 | ensure 91 | ENV.delete('AWS_DYNAMO_DB_SESSION_CONFIG_FILE') 92 | end 93 | end 94 | 95 | it 'ignores unsupported keys in ENV' do 96 | ENV['AWS_DYNAMO_DB_SESSION_DYNAMO_DB_CLIENT'] = 'Client' 97 | ENV['AWS_DYNAMO_DB_SESSION_ERROR_HANDLER'] = 'Handler' 98 | cfg = Aws::SessionStore::DynamoDB::Configuration.new 99 | expect(cfg.to_hash).to include(defaults) 100 | ensure 101 | ENV.delete('AWS_DYNAMO_DB_SESSION_DYNAMO_DB_CLIENT') 102 | ENV.delete('AWS_DYNAMO_DB_SESSION_ERROR_HANDLER') 103 | end 104 | 105 | it 'ignores unsupported keys in YAML' do 106 | Tempfile.create('aws_dynamo_db_session_store.yml') do |f| 107 | options = { dynamo_db_client: 'Client', error_handler: 'Handler', config_file: 'File' } 108 | f << options.transform_keys(&:to_s).to_yaml 109 | f.rewind 110 | cfg = Aws::SessionStore::DynamoDB::Configuration.new(config_file: f.path) 111 | expect(cfg.to_hash).to include(defaults.merge(config_file: f.path)) 112 | end 113 | end 114 | 115 | it 'raises an exception when wrong path for file' do 116 | config_path = 'Wrong path!' 117 | runtime_opts = { config_file: config_path }.merge(options) 118 | expect { Aws::SessionStore::DynamoDB::Configuration.new(runtime_opts) } 119 | .to raise_error(Errno::ENOENT) 120 | end 121 | end 122 | -------------------------------------------------------------------------------- /spec/aws/session_store/dynamo_db/rack_middleware_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | module Aws 6 | module SessionStore 7 | module DynamoDB 8 | describe RackMiddleware do 9 | include Rack::Test::Methods 10 | 11 | def ensure_data_updated(mutated_data) 12 | expect(dynamo_db_client).to receive(:update_item) do |options| 13 | if mutated_data 14 | expect(options[:attribute_updates]['data']).not_to be_nil 15 | else 16 | expect(options[:attribute_updates]['data']).to be_nil 17 | end 18 | end 19 | end 20 | 21 | let(:options) do 22 | { 23 | dynamo_db_client: dynamo_db_client, 24 | secret_key: 'watermelon_cherries' 25 | } 26 | end 27 | 28 | let(:base_app) { MultiplierApplication.new } 29 | let(:app) { RackMiddleware.new(base_app, options) } 30 | 31 | let(:sample_packed_data) do 32 | [Marshal.dump('multiplier' => 1)].pack('m*') 33 | end 34 | 35 | let(:dynamo_db_client) do 36 | Aws::DynamoDB::Client.new( 37 | stub_responses: { 38 | list_tables: { table_names: ['Sessions'] }, 39 | get_item: { item: { 'data' => sample_packed_data } }, 40 | update_item: { attributes: { 'created_at' => 'now' } } 41 | } 42 | ) 43 | end 44 | 45 | it 'stores session data in session object' do 46 | get '/' 47 | expect(last_request.session.to_hash).to eq('multiplier' => 1) 48 | end 49 | 50 | it 'creates a new HTTP cookie when Cookie not supplied' do 51 | get '/' 52 | expect(last_response.body).to eq('All good!') 53 | expect(last_response['Set-Cookie']).to be_truthy 54 | end 55 | 56 | it 'loads/manipulates a session based on id from HTTP-Cookie' do 57 | get '/' 58 | expect(last_request.session.to_hash).to eq('multiplier' => 1) 59 | 60 | get '/' 61 | expect(last_request.session.to_hash).to eq('multiplier' => 2) 62 | end 63 | 64 | it 'does not rewrite Cookie if cookie previously/accuarately set' do 65 | get '/' 66 | expect(last_response['Set-Cookie']).not_to be_nil 67 | 68 | get '/' 69 | expect(last_response['Set-Cookie']).to be_nil 70 | end 71 | 72 | it 'does not set cookie when defer option is specifed' do 73 | options[:defer] = true 74 | get '/' 75 | expect(last_response['Set-Cookie']).to be_nil 76 | end 77 | 78 | it 'creates new session with false/nonexistant http-cookie id' do 79 | get '/' 80 | expect(last_response['Set-Cookie']).not_to eq('1234') 81 | expect(last_response['Set-Cookie']).not_to be_nil 82 | end 83 | 84 | it 'expires after specified time and sets date for cookie to expire' do 85 | options[:expire_after] = 0 86 | get '/' 87 | session_cookie = last_response['Set-Cookie'] 88 | 89 | get '/' 90 | expect(last_response['Set-Cookie']).not_to be_nil 91 | expect(last_response['Set-Cookie']).not_to eq(session_cookie) 92 | end 93 | 94 | it "doesn't reset Cookie if not outside expire date" do 95 | options[:expire_after] = 3600 96 | get '/' 97 | session_cookie = last_response['Set-Cookie'] 98 | get '/' 99 | expect(last_response['Set-Cookie']).to eq(session_cookie) 100 | end 101 | 102 | it 'will not set a session cookie when defer is true' do 103 | options[:defer] = true 104 | get '/' 105 | expect(last_response['Set-Cookie']).to be_nil 106 | end 107 | 108 | it 'generates sid and migrates data to new sid when renew is selected' do 109 | options[:renew] = true 110 | get '/' 111 | expect(last_request.session.to_hash).to eq('multiplier' => 1) 112 | session_cookie = last_response['Set-Cookie'] 113 | 114 | get '/', 'HTTP_Cookie' => session_cookie 115 | expect(last_response['Set-Cookie']).not_to eq(session_cookie) 116 | expect(last_request.session.to_hash).to eq('multiplier' => 2) 117 | end 118 | 119 | it "doesn't resend unmutated data" do 120 | ensure_data_updated(true) 121 | options[:renew] = true 122 | get '/' 123 | 124 | ensure_data_updated(false) 125 | get '/', {}, { 'rack.session' => { 'multiplier' => nil } } 126 | end 127 | end 128 | end 129 | end 130 | end 131 | -------------------------------------------------------------------------------- /lib/aws/session_store/dynamo_db/locking/base.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Aws::SessionStore::DynamoDB::Locking 4 | # Handles session management. 5 | class Base 6 | # @param [Aws::SessionStore::DynamoDB::Configuration] cfg 7 | def initialize(cfg) 8 | @config = cfg 9 | end 10 | 11 | # Updates session in database 12 | def set_session_data(env, sid, session, options = {}) 13 | return false if session.empty? 14 | 15 | packed_session = pack_data(session) 16 | handle_error(env) do 17 | save_opts = update_opts(env, sid, packed_session, options) 18 | @config.dynamo_db_client.update_item(save_opts) 19 | sid 20 | end 21 | end 22 | 23 | # Retrieves session data based on id 24 | def get_session_data(env, sid) 25 | raise NotImplementedError 26 | end 27 | 28 | # Deletes session based on id 29 | def delete_session(env, sid) 30 | handle_error(env) do 31 | @config.dynamo_db_client.delete_item(delete_opts(sid)) 32 | end 33 | end 34 | 35 | private 36 | 37 | # Each database operation is placed in this rescue wrapper. 38 | # This wrapper will call the method, rescue any exceptions and then pass 39 | # exceptions to the configured error handler. 40 | def handle_error(env = nil) 41 | yield 42 | rescue Aws::DynamoDB::Errors::ServiceError => e 43 | @config.error_handler.handle_error(e, env) 44 | end 45 | 46 | # @return [Hash] Options for deleting session. 47 | def delete_opts(sid) 48 | table_opts(sid) 49 | end 50 | 51 | # @return [Hash] Options for updating item in Session table. 52 | def update_opts(env, sid, session, options = {}) 53 | if env['dynamo_db.new_session'] 54 | save_new_opts(env, sid, session) 55 | else 56 | save_exists_opts(env, sid, session, options) 57 | end 58 | end 59 | 60 | # @return [Hash] Options for saving a new session in database. 61 | def save_new_opts(env, sid, session) 62 | attribute_opts = attr_updts(env, session, created_attr) 63 | merge_all(table_opts(sid), attribute_opts) 64 | end 65 | 66 | # @return [Hash] Options for saving an existing sesison in the database. 67 | def save_exists_opts(env, sid, session, options = {}) 68 | add_attr = options[:add_attrs] || {} 69 | expected = options[:expect_attr] || {} 70 | attribute_opts = merge_all(attr_updts(env, session, add_attr), expected) 71 | merge_all(table_opts(sid), attribute_opts) 72 | end 73 | 74 | # Marshal the data. 75 | def pack_data(data) 76 | [Marshal.dump(data)].pack('m*') 77 | end 78 | 79 | # Unmarshal the data. 80 | def unpack_data(packed_data) 81 | Marshal.load(packed_data.unpack1('m*')) 82 | end 83 | 84 | # Table options for client. 85 | def table_opts(sid) 86 | { 87 | table_name: @config.table_name, 88 | key: { @config.table_key => sid } 89 | } 90 | end 91 | 92 | # Attributes to update via client. 93 | def attr_updts(env, session, add_attrs = {}) 94 | data = data_unchanged?(env, session) ? {} : data_attr(session) 95 | { 96 | attribute_updates: merge_all(updated_attr, data, add_attrs, expire_attr), 97 | return_values: 'UPDATED_NEW' 98 | } 99 | end 100 | 101 | # Update client with current time attribute. 102 | def updated_at 103 | { value: Time.now.to_f.to_s, action: 'PUT' } 104 | end 105 | 106 | # Attribute for creation of session. 107 | def created_attr 108 | { 'created_at' => updated_at } 109 | end 110 | 111 | # Update client with current time + max_stale. 112 | def expire_at 113 | max_stale = @config.max_stale || 0 114 | { value: (Time.now + max_stale).to_i, action: 'PUT' } 115 | end 116 | 117 | # Attribute for TTL expiration of session. 118 | def expire_attr 119 | { 'expire_at' => expire_at } 120 | end 121 | 122 | # Attribute for updating session. 123 | def updated_attr 124 | { 125 | 'updated_at' => updated_at 126 | } 127 | end 128 | 129 | def data_attr(session) 130 | { 'data' => { value: session, action: 'PUT' } } 131 | end 132 | 133 | # Determine if data has been manipulated 134 | def data_unchanged?(env, session) 135 | return false unless env['rack.initial_data'] 136 | 137 | env['rack.initial_data'] == session 138 | end 139 | 140 | # Expected attributes 141 | def expected_attributes(sid) 142 | { expected: { @config.table_key => { value: sid, exists: true } } } 143 | end 144 | 145 | # Attributes to be retrieved via client 146 | def attr_opts 147 | { attributes_to_get: ['data'], 148 | consistent_read: @config.consistent_read } 149 | end 150 | 151 | # @return [Hash] merged hash of all hashes passed in. 152 | def merge_all(*hashes) 153 | new_hash = {} 154 | hashes.each { |hash| new_hash.merge!(hash) } 155 | new_hash 156 | end 157 | end 158 | end 159 | -------------------------------------------------------------------------------- /lib/aws/session_store/dynamo_db/locking/pessimistic.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Aws::SessionStore::DynamoDB::Locking 4 | # This class implements a pessimistic locking strategy for the 5 | # DynamoDB session handler. Sessions obtain an exclusive lock 6 | # for reads that is only released when the session is saved. 7 | class Pessimistic < Aws::SessionStore::DynamoDB::Locking::Base 8 | # Saves the session. 9 | def set_session_data(env, sid, session, options = {}) 10 | super(env, sid, session, set_lock_options(env, options)) 11 | end 12 | 13 | # Gets session from database and places a lock on the session 14 | # while you are reading from the database. 15 | def get_session_data(env, sid) 16 | handle_error(env) do 17 | get_session_with_lock(env, sid) 18 | end 19 | end 20 | 21 | private 22 | 23 | # Get session with implemented locking strategy. 24 | # rubocop:disable Metrics/MethodLength 25 | def get_session_with_lock(env, sid) 26 | expires_at = nil 27 | result = nil 28 | max_attempt_date = Time.now.to_f + @config.lock_max_wait_time 29 | while result.nil? 30 | exceeded_wait_time?(max_attempt_date) 31 | begin 32 | result = attempt_set_lock(sid) 33 | rescue Aws::DynamoDB::Errors::ConditionalCheckFailedException 34 | expires_at ||= get_expire_date(sid) 35 | next if expires_at.nil? 36 | 37 | result = bust_lock(sid, expires_at) 38 | wait_to_retry(result) 39 | end 40 | end 41 | get_data(env, result) 42 | end 43 | # rubocop:enable Metrics/MethodLength 44 | 45 | # Determine if session has waited too long to obtain lock. 46 | # 47 | # @raise [Error] When time for attempting to get lock has 48 | # been exceeded. 49 | def exceeded_wait_time?(max_attempt_date) 50 | lock_error = Aws::SessionStore::DynamoDB::Errors::LockWaitTimeoutError 51 | raise lock_error if Time.now.to_f > max_attempt_date 52 | end 53 | 54 | # @return [Hash] Options hash for placing a lock on a session. 55 | def get_lock_time_opts(sid) 56 | merge_all(table_opts(sid), lock_opts) 57 | end 58 | 59 | # @return [Time] Time stamp for which the session was locked. 60 | def lock_time(sid) 61 | result = @config.dynamo_db_client.get_item(get_lock_time_opts(sid)) 62 | result[:item]['locked_at']&.to_f 63 | end 64 | 65 | # @return [String] Session data. 66 | def get_data(env, result) 67 | lock_time = result[:attributes]['locked_at'] 68 | env['locked_at'] = lock_time.to_f 69 | env['rack.initial_data'] = result[:item]['data'] if result.members.include? :item 70 | unpack_data(result[:attributes]['data']) 71 | end 72 | 73 | # Attempt to bust the lock if the expiration date has expired. 74 | def bust_lock(sid, expires_at) 75 | return unless expires_at < Time.now.to_f 76 | 77 | @config.dynamo_db_client.update_item(obtain_lock_opts(sid)) 78 | end 79 | 80 | # @return [Hash] Options hash for obtaining the lock. 81 | def obtain_lock_opts(sid, add_opt = {}) 82 | merge_all(table_opts(sid), lock_attr, add_opt) 83 | end 84 | 85 | # Sleep for given time period if the session is currently locked. 86 | def wait_to_retry(result) 87 | sleep(0.001 * @config.lock_retry_delay) if result.nil? 88 | end 89 | 90 | # Get the expiration date for the session 91 | def get_expire_date(sid) 92 | lock_date = lock_time(sid) 93 | lock_date + (0.001 * @config.lock_expiry_time) if lock_date 94 | end 95 | 96 | # Attempt to place a lock on the session. 97 | def attempt_set_lock(sid) 98 | @config.dynamo_db_client.update_item(obtain_lock_opts(sid, lock_expect)) 99 | end 100 | 101 | # Lock attribute - time stamp of when session was locked. 102 | def lock_attr 103 | { 104 | attribute_updates: { 'locked_at' => updated_at }, 105 | return_values: 'ALL_NEW' 106 | } 107 | end 108 | 109 | # Time in which session was updated. 110 | def updated_at 111 | { value: Time.now.to_f.to_s, action: 'PUT' } 112 | end 113 | 114 | # Attributes for locking. 115 | def add_lock_attrs(env) 116 | { 117 | add_attrs: add_attr, expect_attr: expect_lock_time(env) 118 | } 119 | end 120 | 121 | # Lock options for setting lock. 122 | def set_lock_options(env, options = {}) 123 | merge_all(options, add_lock_attrs(env)) 124 | end 125 | 126 | # Lock expectation. 127 | def lock_expect 128 | { expected: { 'locked_at' => { exists: false } } } 129 | end 130 | 131 | # Option to delete lock. 132 | def add_attr 133 | { 'locked_at' => { action: 'DELETE' } } 134 | end 135 | 136 | # Expectation of when lock was set. 137 | def expect_lock_time(env) 138 | { 139 | expected: { 140 | 'locked_at' => { 141 | value: env['locked_at'].to_s, 142 | exists: true 143 | } 144 | } 145 | } 146 | end 147 | 148 | # Attributes to be retrieved via client 149 | def lock_opts 150 | { 151 | attributes_to_get: ['locked_at'], 152 | consistent_read: @config.consistent_read 153 | } 154 | end 155 | end 156 | end 157 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Amazon DynamoDB Session Store 2 | 3 | This gem handles sessions for Ruby web applications using a DynamoDB backend. 4 | It is compatible with all Rack based frameworks including Rails. 5 | 6 | A DynamoDB backend provides scaling and centralized data benefits for session 7 | storage with more ease than other containers, like local servers or cookies. 8 | Once an application scales beyond a single web server, session data will need to 9 | be shared across the servers. Cookie storage places all session data on the 10 | client side, discouraging sensitive data storage. It also forces strict data size 11 | limitations. DynamoDB takes care of these concerns by allowing for a safe and 12 | scalable storage container with a much larger data size limit for session data. 13 | 14 | For more developer information, see the 15 | [Full API documentation](https://docs.aws.amazon.com/sdk-for-ruby/aws-sessionstore-dynamodb/api/). 16 | 17 | ## Installation 18 | 19 | Add this gem to your Rack application's Gemfile: 20 | 21 | ```ruby 22 | gem 'aws-sessionstore-dynamodb', '~> 3' 23 | ``` 24 | 25 | If you are using Rails, please include the 26 | [aws-sdk-rails](https://github.com/aws/aws-sdk-rails) 27 | gem for 28 | [extra functionality](https://github.com/aws/aws-sdk-rails?tab=readme-ov-file#dynamodb-session-store), 29 | including generators for the session table, ActionDispatch Session integration, 30 | a garbage collection Rake task, and more: 31 | 32 | ```ruby 33 | gem 'aws-sdk-rails', '~> 4' 34 | ``` 35 | 36 | ## Configuration 37 | 38 | A number of options are available to be set in 39 | `Aws::SessionStore::DynamoDB::Configuration`, which is used throughout the 40 | application. These options can be set directly in Ruby code, in ENV variables, 41 | or in a YAML configuration file, in order of precedence. 42 | 43 | The full set of options along with defaults can be found in the 44 | [Configuration](https://docs.aws.amazon.com/sdk-for-ruby/aws-sessionstore-dynamodb/api/Aws/SessionStore/DynamoDB/Configuration.html) 45 | documentation. 46 | 47 | ### Environment Options 48 | 49 | All Configuration options can be loaded from the environment except for 50 | `:dynamo_db_client` and `:error_handler`, which must be set in Ruby code 51 | directly if needed. The environment options must be prefixed with 52 | `AWS_DYNAMO_DB_SESSION_` and then the name of the option: 53 | 54 | AWS_DYNAMO_DB_SESSION_ 55 | 56 | The example below would be a valid way to set the session table name: 57 | 58 | export AWS_DYNAMO_DB_SESSION_TABLE_NAME='your-table-name' 59 | 60 | ### YAML Configuration 61 | 62 | You can create a YAML configuration file to set the options. The file must be 63 | passed into Configuration as the `:config_file` option or with the 64 | `AWS_DYNAMO_DB_SESSION_CONFIG_FILE` environment variable. 65 | 66 | ## Creating the session table 67 | 68 | After installation and configuration, you must create the session table using 69 | the following Ruby methods: 70 | 71 | ```ruby 72 | options = { table_name: 'your-table-name' } # overrides from YAML or ENV 73 | Aws::SessionStore::DynamoDB::Table.create_table(options) 74 | Aws::SessionStore::DynamoDB::Table.delete_table(options) 75 | ``` 76 | 77 | ## Usage 78 | 79 | Run the session store as a Rack middleware in the following way: 80 | 81 | ```ruby 82 | require 'aws-sessionstore-dynamodb' 83 | require 'some_rack_app' 84 | 85 | options = { :secret_key => 'secret' } # overrides from YAML or ENV 86 | 87 | use Aws::SessionStore::DynamoDB::RackMiddleware.new(options) 88 | run SomeRackApp 89 | ``` 90 | 91 | Note that `:secret_key` is a mandatory configuration option that must be set. 92 | 93 | `RackMiddleware` inherits from the `Rack::Session::Abstract::Persisted` class, 94 | which also includes additional options (such as `:key`) that can be set. 95 | 96 | The `RackMiddleware` inherits from the 97 | [Rack::Session::Abstract::Persisted](https://rubydoc.info/github/rack/rack-session/main/Rack/Session/Abstract/Persisted) 98 | class, which also includes additional options (such as `:key`) that can be 99 | passed into the class. 100 | 101 | ### Garbage Collection 102 | 103 | By default sessions do not expire. You can use `:max_age` and `:max_stale` to 104 | configure the max age or stale period of a session. 105 | 106 | You can use the DynamoDB 107 | [Time to Live (TTL) feature](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/TTL.html) 108 | on the `expire_at` attribute to automatically delete expired items, saving you 109 | the trouble of manually deleting them and reducing costs. 110 | 111 | If you wish to delete old sessions based on creation age (invalidating valid 112 | sessions) or if you want control over the garbage collection process, you can 113 | create your own Rake task: 114 | 115 | ```ruby 116 | desc 'Perform Garbage Collection' 117 | task :clean_session_table do 118 | options = { max_age: 3600*24, max_stale: 5*3600 } # overrides from YAML or ENV 119 | Aws::SessionStore::DynamoDB::GarbageCollection.collect_garbage(options) 120 | end 121 | ``` 122 | 123 | The above example will clear sessions older than one day or that have been 124 | stale for longer than an hour. 125 | 126 | ### Error Handling 127 | 128 | You can pass in your own error handler for raised exceptions or you can allow 129 | the default error handler to them for you. See the 130 | [BaseHandler](https://docs.aws.amazon.com/sdk-for-ruby/aws-sessionstore-dynamodb/api/Aws/SessionStore/DynamoDB/Errors/BaseHandler.html) 131 | documentation for more details on how to create your own error handler. 132 | -------------------------------------------------------------------------------- /lib/aws/session_store/dynamo_db/configuration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'aws-sdk-dynamodb' 4 | 5 | module Aws::SessionStore::DynamoDB 6 | # This class provides a Configuration object for all DynamoDB session store operations 7 | # by pulling configuration options from Runtime, the ENV, a YAML file, and default 8 | # settings, in that order. 9 | # 10 | # # Environment Variables 11 | # The Configuration object can load default values from your environment. All configuration 12 | # keys are supported except for `:dynamo_db_client` and `:error_handler`. The keys take the form 13 | # of AWS_DYNAMO_DB_SESSION_. Example: 14 | # 15 | # export AWS_DYNAMO_DB_SESSION_TABLE_NAME='Sessions' 16 | # export AWS_DYNAMO_DB_SESSION_TABLE_KEY='id' 17 | # 18 | # # Locking Strategy 19 | # By default, locking is disabled for session store access. To enable locking, set the 20 | # `:enable_locking` option to true. The locking strategy is pessimistic, meaning that only one 21 | # read can be made on a session at once. While the session is being read by the process with the 22 | # lock, other processes may try to obtain a lock on the same session but will be blocked. 23 | # See the initializer for how to configure the pessimistic locking strategy to your needs. 24 | # 25 | # # Handling Errors 26 | # There are two configurable options for error handling: `:raise_errors` and `:error_handler`. 27 | # 28 | # If you would like to use the Default Error Handler, you can decide to set `:raise_errors` 29 | # to true or false depending on whether you want all errors, regardless of class, to be raised 30 | # up the stack and essentially throw a 500. 31 | # 32 | # If you decide to use your own Error Handler, you must implement the `BaseErrorHandler` 33 | # class and pass it into the `:error_handler` option. 34 | # @see BaseHandler Interface for Error Handling for DynamoDB Session Store. 35 | # 36 | # # DynamoDB Specific Options 37 | # You may configure the table name and table hash key value of your session table with 38 | # the `:table_name` and `:table_key` options. You may also configure performance options for 39 | # your table with the `:consistent_read`, `:read_capacity`, `:write_capacity`. For more information 40 | # about these configurations see 41 | # {https://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/DynamoDB/Client.html#create_table-instance_method CreateTable} 42 | # method for Amazon DynamoDB. 43 | # 44 | class Configuration 45 | # @api private 46 | MEMBERS = { 47 | table_name: 'sessions', 48 | table_key: 'session_id', 49 | secret_key: nil, 50 | consistent_read: true, 51 | read_capacity: 10, 52 | write_capacity: 5, 53 | raise_errors: false, 54 | error_handler: nil, 55 | max_age: nil, 56 | max_stale: nil, 57 | enable_locking: false, 58 | lock_expiry_time: 500, 59 | lock_retry_delay: 500, 60 | lock_max_wait_time: 1, 61 | config_file: nil, 62 | dynamo_db_client: nil 63 | }.freeze 64 | 65 | # Provides configuration object that allows access to options defined 66 | # during Runtime, in the ENV, in a YAML file, and by default. 67 | # 68 | # @option options [String] :table_name ("sessions") Name of the session table. 69 | # @option options [String] :table_key ("session_id") The hash key of the session table. 70 | # @option options [String] :secret_key Secret key for HMAC encryption. 71 | # @option options [Boolean] :consistent_read (true) If true, a strongly consistent read is used. 72 | # If false, an eventually consistent read is used. 73 | # @see https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/HowItWorks.ReadConsistency.html 74 | # @option options [Integer] :read_capacity (10) The maximum number of strongly consistent reads 75 | # consumed per second before DynamoDB raises a ThrottlingException. 76 | # @see https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/read-write-operations.html 77 | # @option options [Integer] :write_capacity (5) The maximum number of writes 78 | # consumed per second before DynamoDB returns a ThrottlingException. 79 | # @see https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/read-write-operations.html 80 | # @option options [Boolean] :raise_errors (false) If true, all errors are raised up the stack 81 | # when default ErrorHandler. If false, Only specified errors are raised up the stack when 82 | # the default ErrorHandler is used. 83 | # @option options [#handle_error] :error_handler (Errors::DefaultHandler) An error handling object 84 | # that handles all exceptions thrown during execution of the rack application. 85 | # @option options [Integer] :max_age (nil) Maximum number of seconds earlier 86 | # from the current time that a session was created. 87 | # @option options [Integer] :max_stale (nil) Maximum number of seconds 88 | # before current time that session was last accessed. 89 | # @option options [Integer] :enable_locking (false) If true, a pessimistic locking strategy will be 90 | # used for all session accesses. 91 | # @option options [Integer] :lock_expiry_time (500) Time in milliseconds after which the lock 92 | # expires on session. 93 | # @option options [Integer] :lock_retry_delay (500) Time in milliseconds to wait before retrying 94 | # to obtain lock once an attempt to obtain the lock has been made and has failed. 95 | # @option options [Integer] :lock_max_wait_time (500) Maximum time in seconds to wait to acquire the 96 | # lock before giving up. 97 | # @option options [String, Pathname] :config_file 98 | # Path to a YAML file that contains configuration options. 99 | # @option options [Aws::DynamoDB::Client] :dynamo_db_client (Aws::DynamoDB::Client.new) 100 | # DynamoDB client used to perform database operations inside of the rack application. 101 | def initialize(options = {}) 102 | opts = options 103 | opts = env_options.merge(opts) 104 | opts = file_options(opts).merge(opts) 105 | MEMBERS.each_pair do |opt_name, default_value| 106 | opts[opt_name] = default_value unless opts.key?(opt_name) 107 | end 108 | opts = opts.merge(dynamo_db_client: default_dynamo_db_client(opts)) 109 | opts = opts.merge(error_handler: default_error_handler(opts)) unless opts[:error_handler] 110 | 111 | set_attributes(opts) 112 | end 113 | 114 | MEMBERS.each_key do |attr_name| 115 | attr_reader(attr_name) 116 | end 117 | 118 | # @return [Hash] The merged configuration hash. 119 | def to_hash 120 | MEMBERS.each_with_object({}) do |(key, _), hash| 121 | hash[key] = send(key) 122 | end 123 | end 124 | 125 | private 126 | 127 | def default_dynamo_db_client(options) 128 | dynamo_db_client = options[:dynamo_db_client] || Aws::DynamoDB::Client.new 129 | dynamo_db_client.config.user_agent_frameworks << 'aws-sessionstore-dynamodb' 130 | dynamo_db_client 131 | end 132 | 133 | def default_error_handler(options) 134 | Aws::SessionStore::DynamoDB::Errors::DefaultHandler.new(options[:raise_errors]) 135 | end 136 | 137 | # @return [Hash] Environment options. 138 | def env_options 139 | unsupported_keys = %i[dynamo_db_client error_handler] 140 | (MEMBERS.keys - unsupported_keys).each_with_object({}) do |opt_name, opts| 141 | key = env_key(opt_name) 142 | next unless ENV.key?(key) 143 | 144 | opts[opt_name] = parse_env_value(key) 145 | end 146 | end 147 | 148 | def env_key(opt_name) 149 | # legacy - remove this in aws-sessionstore-dynamodb ~> 4 150 | key = "DYNAMO_DB_SESSION_#{opt_name.to_s.upcase}" 151 | if ENV.key?(key) 152 | Kernel.warn("The environment variable `#{key}` is deprecated. 153 | Please use `AWS_DYNAMO_DB_SESSION_#{opt_name.to_s.upcase}` instead.") 154 | else 155 | key = "AWS_DYNAMO_DB_SESSION_#{opt_name.to_s.upcase}" 156 | end 157 | key 158 | end 159 | 160 | def parse_env_value(key) 161 | val = ENV.fetch(key, nil) 162 | Integer(val) 163 | rescue ArgumentError 164 | %w[true false].include?(val) ? val == 'true' : val 165 | end 166 | 167 | # @return [Hash] File options. 168 | def file_options(options = {}) 169 | if options[:config_file] 170 | load_from_file(options[:config_file]) 171 | else 172 | {} 173 | end 174 | end 175 | 176 | # Load options from the YAML file. 177 | def load_from_file(file_path) 178 | require 'erb' 179 | require 'yaml' 180 | opts = YAML.safe_load(ERB.new(File.read(file_path)).result) || {} 181 | unsupported_keys = %i[dynamo_db_client error_handler config_file] 182 | opts.transform_keys(&:to_sym).reject { |k, _| unsupported_keys.include?(k) } 183 | end 184 | 185 | def set_attributes(options) 186 | MEMBERS.each_key do |attr_name| 187 | instance_variable_set("@#{attr_name}", options[attr_name]) 188 | end 189 | end 190 | end 191 | end 192 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | --------------------------------------------------------------------------------