├── .document ├── .github └── workflows │ └── main.yml ├── .gitignore ├── .rspec ├── .rubocop.yml ├── .rubocop_todo.yml ├── Appraisals ├── CHANGELOG.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── VERSION ├── api_auth.gemspec ├── gemfiles ├── rails_60.gemfile ├── rails_61.gemfile └── rails_70.gemfile ├── lib ├── api-auth.rb ├── api_auth.rb ├── api_auth │ ├── base.rb │ ├── errors.rb │ ├── headers.rb │ ├── helpers.rb │ ├── railtie.rb │ └── request_drivers │ │ ├── action_controller.rb │ │ ├── action_dispatch.rb │ │ ├── curb.rb │ │ ├── faraday.rb │ │ ├── faraday_env.rb │ │ ├── grape_request.rb │ │ ├── http.rb │ │ ├── httpi.rb │ │ ├── net_http.rb │ │ ├── rack.rb │ │ └── rest_client.rb └── faraday │ ├── api_auth.rb │ └── api_auth │ └── middleware.rb └── spec ├── api_auth_spec.rb ├── faraday_middleware_spec.rb ├── fixtures └── upload.png ├── headers_spec.rb ├── helpers_spec.rb ├── railtie_spec.rb ├── request_drivers ├── action_controller_spec.rb ├── action_dispatch_spec.rb ├── curb_spec.rb ├── faraday_env_spec.rb ├── faraday_spec.rb ├── grape_request_spec.rb ├── http_spec.rb ├── httpi_spec.rb ├── net_http_spec.rb ├── rack_spec.rb └── rest_client_spec.rb └── spec_helper.rb /.document: -------------------------------------------------------------------------------- 1 | lib/**/*.rb 2 | bin/* 3 | - 4 | features/**/*.feature 5 | LICENSE.txt 6 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: main 2 | on: 3 | - push 4 | - pull_request 5 | jobs: 6 | rspec: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | fail-fast: true 10 | matrix: 11 | ruby-version: 12 | - 2.6 13 | - 2.7 14 | - 3.0 15 | - 3.1 16 | gemfile: 17 | - rails_60.gemfile 18 | - rails_61.gemfile 19 | - rails_70.gemfile 20 | exclude: 21 | - ruby-version: 3.0 22 | gemfile: rails_60.gemfile 23 | - ruby-version: 3.1 24 | gemfile: rails_60.gemfile 25 | - ruby-version: 2.6 26 | gemfile: rails_70.gemfile 27 | - ruby-version: 2.7 28 | gemfile: rails_70.gemfile 29 | steps: 30 | - name: Install packages required for `curb` gem 31 | run: | 32 | sudo apt-get update 33 | sudo apt-get install -y libcurl4 libcurl3-gnutls libcurl4-openssl-dev 34 | 35 | - name: Checkout repository 36 | uses: actions/checkout@v2 37 | 38 | - name: Install Ruby 39 | uses: ruby/setup-ruby@v1 40 | with: 41 | ruby-version: ${{ matrix.ruby-version }} 42 | bundler-cache: true 43 | 44 | - name: Install required gems 45 | run: BUNDLE_GEMFILE=gemfiles/${{ matrix.gemfile }} bundle install 46 | 47 | - name: Run rspec tests 48 | run: BUNDLE_GEMFILE=gemfiles/${{ matrix.gemfile }} bundle exec rspec 49 | 50 | rubocop: 51 | runs-on: ubuntu-latest 52 | steps: 53 | - name: Install packages required for `curb` gem 54 | run: | 55 | sudo apt-get update 56 | sudo apt-get install -y libcurl4 libcurl3-gnutls libcurl4-openssl-dev 57 | 58 | - name: Checkout repository 59 | uses: actions/checkout@v2 60 | 61 | - name: Install Ruby 62 | uses: ruby/setup-ruby@v1 63 | with: 64 | ruby-version: 3.1 65 | bundler-cache: true 66 | 67 | - name: Install required gems 68 | run: bundle install 69 | 70 | - name: Run rubocop 71 | run: bundle exec rubocop 72 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle 2 | /.ruby-version 3 | /.rvmrc 4 | /Gemfile.lock 5 | /rdoc 6 | /pkg 7 | /coverage 8 | /doc 9 | /.yardoc 10 | gemfiles/*.lock 11 | gemfiles/.bundle/ 12 | /.idea 13 | *.gem 14 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --colour 2 | --format doc 3 | --backtrace 4 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_from: .rubocop_todo.yml 2 | 3 | AllCops: 4 | NewCops: enable 5 | TargetRubyVersion: 2.6 6 | 7 | Metrics/AbcSize: 8 | Max: 28 9 | 10 | # Configuration parameters: AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, IgnoredPatterns. 11 | # URISchemes: http, https 12 | Layout/LineLength: 13 | Max: 140 14 | 15 | Metrics/MethodLength: 16 | Max: 40 17 | 18 | Metrics/BlockLength: 19 | Exclude: 20 | - 'spec/**/*.rb' 21 | - 'api_auth.gemspec' 22 | 23 | Naming/FileName: 24 | Exclude: 25 | - 'lib/api-auth.rb' 26 | 27 | Style/FrozenStringLiteralComment: 28 | Enabled: false 29 | 30 | Style/StringLiterals: 31 | Exclude: 32 | - 'gemfiles/*.gemfile' 33 | 34 | Lint/DuplicateBranch: 35 | Enabled: false 36 | -------------------------------------------------------------------------------- /.rubocop_todo.yml: -------------------------------------------------------------------------------- 1 | # This configuration was generated by 2 | # `rubocop --auto-gen-config` 3 | # on 2022-08-03 07:19:11 UTC using RuboCop version 1.32.0. 4 | # The point is for the user to remove these configuration records 5 | # one by one as the offenses are removed from the code base. 6 | # Note that changes in the inspected code, or installation of new 7 | # versions of RuboCop, may require this file to be generated again. 8 | 9 | # Offense count: 1 10 | # Configuration parameters: AllowSafeAssignment. 11 | Lint/AssignmentInCondition: 12 | Exclude: 13 | - 'lib/api_auth/base.rb' 14 | 15 | # Offense count: 4 16 | # Configuration parameters: AllowedMethods. 17 | # AllowedMethods: enums 18 | Lint/ConstantDefinitionInBlock: 19 | Exclude: 20 | - 'spec/railtie_spec.rb' 21 | 22 | # Offense count: 9 23 | # Configuration parameters: CheckForMethodsWithNoSideEffects. 24 | Lint/Void: 25 | Exclude: 26 | - 'lib/api_auth/headers.rb' 27 | - 'lib/api_auth/request_drivers/action_controller.rb' 28 | - 'lib/api_auth/request_drivers/curb.rb' 29 | - 'lib/api_auth/request_drivers/faraday.rb' 30 | - 'lib/api_auth/request_drivers/grape_request.rb' 31 | - 'lib/api_auth/request_drivers/httpi.rb' 32 | - 'lib/api_auth/request_drivers/net_http.rb' 33 | - 'lib/api_auth/request_drivers/rack.rb' 34 | - 'lib/api_auth/request_drivers/rest_client.rb' 35 | 36 | # Offense count: 2 37 | # Configuration parameters: IgnoredMethods. 38 | Metrics/CyclomaticComplexity: 39 | Max: 16 40 | 41 | # Offense count: 11 42 | Naming/AccessorMethodName: 43 | Exclude: 44 | - 'lib/api_auth/railtie.rb' 45 | - 'lib/api_auth/request_drivers/action_controller.rb' 46 | - 'lib/api_auth/request_drivers/curb.rb' 47 | - 'lib/api_auth/request_drivers/faraday.rb' 48 | - 'lib/api_auth/request_drivers/faraday_env.rb' 49 | - 'lib/api_auth/request_drivers/grape_request.rb' 50 | - 'lib/api_auth/request_drivers/http.rb' 51 | - 'lib/api_auth/request_drivers/httpi.rb' 52 | - 'lib/api_auth/request_drivers/net_http.rb' 53 | - 'lib/api_auth/request_drivers/rack.rb' 54 | - 'lib/api_auth/request_drivers/rest_client.rb' 55 | 56 | # Offense count: 3 57 | # Configuration parameters: MinNameLength, AllowNamesEndingInNumbers, AllowedNames, ForbiddenNames. 58 | # AllowedNames: at, by, db, id, in, io, ip, of, on, os, pp, to 59 | Naming/MethodParameterName: 60 | Exclude: 61 | - 'lib/api_auth/base.rb' 62 | - 'spec/railtie_spec.rb' 63 | 64 | # Offense count: 3 65 | # Configuration parameters: AllowedConstants. 66 | Style/Documentation: 67 | Exclude: 68 | - 'spec/**/*' 69 | - 'test/**/*' 70 | - 'lib/api_auth/railtie.rb' 71 | 72 | # Offense count: 1 73 | # Configuration parameters: AllowedMethods. 74 | # AllowedMethods: respond_to_missing? 75 | Style/OptionalBooleanParameter: 76 | Exclude: 77 | - 'lib/api_auth/railtie.rb' 78 | -------------------------------------------------------------------------------- /Appraisals: -------------------------------------------------------------------------------- 1 | appraise 'rails-52' do 2 | gem 'actionpack', '~> 5.2' 3 | gem 'activeresource', '~> 5.1' 4 | gem 'activesupport', '~> 5.2' 5 | end 6 | 7 | appraise 'rails-60' do 8 | gem 'actionpack', '~> 6.0' 9 | gem 'activeresource', '~> 5.1' 10 | gem 'activesupport', '~> 6.0' 11 | end 12 | 13 | appraise 'rails-61' do 14 | gem 'actionpack', '~> 6.1' 15 | gem 'activeresource', '~> 5.1' 16 | gem 'activesupport', '~> 6.1' 17 | end 18 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 2.5.0 (2021-05-11) 2 | - Add support for Ruby 3.0 (#194 fwininger) 3 | - Add support for Rails 6.1 (#194 fwininger) 4 | - Drop support for Ruby 2.4 (#193 fwininger) 5 | - Drop support for Rails 5.0 (#194 fwininger) 6 | - Drop support for Rails 5.1 (#194 fwininger) 7 | - Fix Faraday warning: `WARNING: Faraday::Request#method is deprecated` (#191 fwininger) 8 | 9 | # 2.4.1 (2020-06-23) 10 | - Fix inadvertant ActiveSupport dependecy (#189 taylorthurlow) 11 | 12 | # 2.4.0 (2020-05-05) 13 | - Improved support for Rails 6.0 (#179 taylorthurlow, #177 fwininger) 14 | - Added Ruby 2.6.0 support (#174 fwininger) 15 | - README updates (#186 iranthau) 16 | 17 | # 2.3.1 (2018-11-06) 18 | - Fixed a regression in the http.rb driver (#173 tycooon) 19 | 20 | # 2.3.0 (2018-10-23) 21 | - Added support for Grape API (#169 phuongnd08 & dunghuynh) 22 | - Added option for specifying customer headers to sign via new `headers_to_sign` 23 | argument (#170 fakenine) 24 | - Fix tests and drop support for Ruby < 2.3 (#171 fwininger) 25 | 26 | # 2.2.0 (2018-03-12) 27 | - Drop support ruby 1.x, rails 2.x, rails 3.x (#141 fwininger) 28 | - Add http.rb request driver (#164 tycooon) 29 | - Fix POST and PUT requests in RestClient (#151 fwininger) 30 | - Allow clock skew to be user-defined (#136 mlarraz) 31 | - Adds #original_uri method to all request drivers (#137 iMacTia) 32 | - Rubocop and test fixes (fwininger & nicolasleger) 33 | - Changed return type for request #content_md5 #timestamp #content_type (fwininger) 34 | - Fix URI edge case where a URI contains another URI (zfletch) 35 | - Updates to the README (zfletch) 36 | 37 | # 2.1.0 (2016-12-22) 38 | - Fixed a NoMethodError that might occur when using the NetHttp Driver (#130 grahamkenville) 39 | - More securely compare signatures in a way that prevents timing attacks (#56 leishman, #133 will0) 40 | - Remove support for MD2 and MD4 hashing algorithms since they are insecure (#134 will0) 41 | - Disallow requests that are too far in the future to limit the time available for a brute force signature guess (#119 fwininger) 42 | 43 | # 2.0.1 (2016-07-25) 44 | - Support of `api_auth_options` in ActiveResource integration (#102 fwininger) 45 | - Replace use of `#blank?` with `#nil?` to not depend on ActiveSupport (#114 packrat386) 46 | - Fix Auth header matching to not match invalid SHA algorithms (#115 packrat386) 47 | - Replace `alias_method_chain` with `alias_method` in the railtie since 48 | alias_method_chain is deprecated in Rails 5 (#118 mlarraz) 49 | 50 | # 2.0.0 (2016-05-11) 51 | - IMPORTANT: 2.0.0 is backwards incompatible with the default settings of v1.x 52 | v2.0.0 always includes the http method in the canonical string. 53 | You can use the upgrade strategy in v1.4.x and above to migrate to v2.0.0 54 | without any down time. Please see the 1.4.0 release nodes for more info 55 | - Added support for other digest algorithms like SHA-256 (#98 fwininger) 56 | 57 | # 1.5.0 (2016-01-21) 58 | - Added a sign_with_http_method configuration option to the ActiveResource 59 | rails tie to correspond to passing the `:with_http_method => true` into 60 | `ApiAuth.sign!` 61 | 62 | # 1.4.1 (2016-01-04) 63 | - Fixed an issue where getters wouldn't immediately have the correct value after 64 | setting a date or content md5 in some of the request drivers (#91) 65 | 66 | # 1.4.0 (2015-12-16) 67 | 68 | ## IMPORTANT SECURITY FIX (with backwards compatible fallback) 69 | 70 | This version introduces a security fix. In previous versions, the canonical 71 | string does not include the http method used to make the request, this means 72 | two requests that would otherwise be identical (such as a GET and DELETE) 73 | would have the same signature allowing for a MITM to swap one method for 74 | another. 75 | 76 | In ApiAuth v1.4 `ApiAuth.authentic?` will allow for requests signed using either 77 | the canonical string WITH the http method, or WITHOUT it. `ApiAuth.sign!` will, 78 | by default, still sign the request using the canonical string without the 79 | method. However, passing in the `:with_http_method => true` option into 80 | `ApiAuth.sign?` will cause the request to use the http method as part of the 81 | canonical string. 82 | 83 | Example: 84 | 85 | ```ruby 86 | ApiAuth.sign!(request, access_id, secret_key, {:with_http_method => true}) 87 | ``` 88 | 89 | This allows for an upgrade strategy that would look like the following. 90 | 91 | 1. Update server side code to use ApiAuth v1.4 92 | 2. Update client side code to use ApiAuth v1.4 93 | 3. Update all client side code to sign with http method 94 | 4. Update server side code to ApiAuth v2.0 (removes the ability to authenticate without the http method) 95 | 5. Update all client side code to ApiAuth v2.0 (forces all signatures to contain the http method) 96 | 97 | ## Additional changes 98 | 99 | - Performance enhancement: reduce allocation of Headers object (#81 pd) 100 | - Performance enhancement: avoid reallocating static Regexps (#82 pd) 101 | 102 | # 1.3.2 (2015-08-28) 103 | - Fixed a bug where some client adapters didn't treat an empty path as 104 | "/" in the canonical string (#75 managr) 105 | 106 | # 1.3.1 (2015-03-13) 107 | - Fixed a bug where Faraday requests with no parameters were not signed 108 | correctly (#65 nathanhoel) 109 | 110 | # 1.3.0 (2015-03-12) 111 | - Add a Faraday Request Driver (#64 nathanhoel) 112 | 113 | # 1.2.6 (2014-10-01) 114 | - Fix a bug in the ActionController request driver where calculated_md5 was 115 | incorrect in certain scenarios. (#53 karl-petter) 116 | 117 | # 1.2.5 (2014-09-09) 118 | - Fix a bug where ApiAuth.authentic? would cause an ArgumentError when given a 119 | request with an invalid date in the date header. It will now return false 120 | instead. (#51 Nakort) 121 | 122 | # 1.2.4 (2014-08-27) 123 | - Fix a bug in the Net::HTTP request driver where the md5 isn't calculated 124 | correctly when the content of the request is set with the `.body_stream` 125 | method. (#49 adamcrown) 126 | 127 | # 1.2.3 (2014-08-01) 128 | - Update action controller request driver to fix a bug with OLD versions of 129 | Rails using CGI 130 | 131 | # 1.2.2 (2014-07-08) 132 | - Fix Rest Client driver to account for the generated date when signing (cjeeky) 133 | 134 | # 1.2.1 (2014-07-03) 135 | 136 | - Fix Rest Client driver to account for the generated md5 when signing 137 | (#45 cjeeky) 138 | - Support for testing against Rails 4.1 (#42 awendt) 139 | - Support all requests inheriting from Rack::Request (#43 mcls) 140 | 141 | # 1.2.0 (2014-05-16) 142 | 143 | - Fix ruby 1.8.7 support 144 | - Test / support all major versions of rails 2.3 - 4.0 145 | - Add support for sinatra requests 146 | - Add support for HTTPI requests 147 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | gemspec 3 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 Mauricio Gomes 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ApiAuth 2 | 3 | [![Build Status](https://github.com/mgomes/api_auth/actions/workflows/main.yml/badge.svg?branch=master)](https://github.com/mgomes/api_auth/actions) 4 | [![Gem Version](https://badge.fury.io/rb/api-auth.svg)](https://badge.fury.io/rb/api-auth) 5 | 6 | Logins and passwords are for humans. Communication between applications need to 7 | be protected through different means. 8 | 9 | ApiAuth is a Ruby gem designed to be used both in your client and server 10 | HTTP-based applications. It implements the same authentication methods (HMAC-SHA2) 11 | used by Amazon Web Services. 12 | 13 | The gem will sign your requests on the client side and authenticate that 14 | signature on the server side. If your server resources are implemented as a 15 | Rails ActiveResource, it will integrate with that. It will even generate the 16 | secret keys necessary for your clients to sign their requests. 17 | 18 | Since it operates entirely using HTTP headers, the server component does not 19 | have to be written in the same language as the clients. 20 | 21 | ## How it works 22 | 23 | 1. A canonical string is first created using your HTTP headers containing the 24 | `content-type`, `X-Authorization-Content-SHA256`, request path and the date/time stamp. 25 | If `content-type` or `X-Authorization-Content-SHA256` are not present, then a blank 26 | string is used in their place. If the timestamp isn't present, a valid HTTP date is 27 | automatically added to the request. The canonical string is computed as follows: 28 | 29 | ```ruby 30 | canonical_string = "#{http method},#{content-type},#{X-Authorization-Content-SHA256},#{request URI},#{timestamp}" 31 | ``` 32 | 33 | e.g., 34 | 35 | ```ruby 36 | canonical_string = 'POST,application/json,,request_path,Tue, 30 May 2017 03:51:43 GMT' 37 | ``` 38 | 39 | 2. This string is then used to create the signature which is a Base64 encoded 40 | SHA1 HMAC, using the client's private secret key. 41 | 42 | 3. This signature is then added as the `Authorization` HTTP header in the form: 43 | 44 | ```ruby 45 | Authorization = APIAuth "#{client access id}:#{signature from step 2}" 46 | ``` 47 | 48 | A cURL request would look like: 49 | 50 | ```sh 51 | curl -X POST --header 'Content-Type: application/json' --header "Date: Tue, 30 May 2017 03:51:43 GMT" --header "Authorization: ${AUTHORIZATION}" https://my-app.com/request_path` 52 | ``` 53 | 54 | 5. On the server side, the SHA2 HMAC is computed in the same way using the 55 | request headers and the client's secret key, which is known to only 56 | the client and the server but can be looked up on the server using the client's 57 | access id that was attached in the header. The access id can be any integer or 58 | string that uniquely identifies the client. The signed request expires after 15 59 | minutes in order to avoid replay attacks. 60 | 61 | ## References 62 | 63 | * [Hash functions](https://en.wikipedia.org/wiki/Cryptographic_hash_function) 64 | * [SHA-2 Hash function](https://en.wikipedia.org/wiki/SHA-2) 65 | * [HMAC algorithm](https://en.wikipedia.org/wiki/HMAC) 66 | * [RFC 2104 (HMAC)](https://tools.ietf.org/html/rfc2104) 67 | 68 | ## Requirement 69 | 70 | This gem require Ruby >= 2.6 and Rails >= 6.0 if you use rails. 71 | 72 | ## Install 73 | 74 | The gem doesn't have any dependencies outside of having a working OpenSSL 75 | configuration for your Ruby VM. To install: 76 | 77 | ```sh 78 | [sudo] gem install api-auth 79 | ``` 80 | 81 | Please note the dash in the name versus the underscore. 82 | 83 | ## Clients 84 | 85 | ApiAuth supports many popular HTTP clients. Support for other clients can be 86 | added as a request driver. 87 | 88 | Here is the current list of supported request objects: 89 | 90 | * Net::HTTP 91 | * ActionDispatch::Request 92 | * Curb (Curl::Easy) 93 | * RestClient 94 | * Faraday 95 | * HTTPI 96 | * HTTP 97 | 98 | ### HTTP Client Objects 99 | 100 | Here's a sample implementation of signing a request created with RestClient. 101 | 102 | Assuming you have a client access id and secret as follows: 103 | 104 | ```ruby 105 | @access_id = "1044" 106 | @secret_key = ApiAuth.generate_secret_key 107 | ``` 108 | 109 | A typical RestClient PUT request may look like: 110 | 111 | ```ruby 112 | headers = { 'X-Authorization-Content-SHA256' => "dWiCWEMZWMxeKM8W8Yuh/TbI29Hw5xUSXZWXEJv63+Y=", 113 | 'Content-Type' => "text/plain", 114 | 'Date' => "Mon, 23 Jan 1984 03:29:56 GMT" 115 | } 116 | 117 | @request = RestClient::Request.new( 118 | url: "/resource.xml?foo=bar&bar=foo", 119 | headers: headers, 120 | method: :put 121 | ) 122 | ``` 123 | 124 | To sign that request, simply call the `sign!` method as follows: 125 | 126 | ```ruby 127 | @signed_request = ApiAuth.sign!(@request, @access_id, @secret_key) 128 | ``` 129 | 130 | The proper `Authorization` request header has now been added to that request 131 | object and it's ready to be transmitted. It's recommended that you sign the 132 | request as one of the last steps in building the request to ensure the headers 133 | don't change after the signing process which would cause the authentication 134 | check to fail on the server side. 135 | 136 | If you are signing a request for a driver that doesn't support automatic http 137 | method detection (like Curb or httpi), you can pass the http method as an option 138 | into the sign! method like so: 139 | 140 | ```ruby 141 | @signed_request = ApiAuth.sign!(@request, @access_id, @secret_key, :override_http_method => "PUT") 142 | ``` 143 | 144 | If you want to use another digest existing in `OpenSSL::Digest`, 145 | you can pass the http method as an option into the sign! method like so: 146 | 147 | ```ruby 148 | @signed_request = ApiAuth.sign!(@request, @access_id, @secret_key, :digest => 'sha256') 149 | ``` 150 | 151 | With the `digest` option, the `Authorization` header will be change from: 152 | 153 | ```sh 154 | Authorization = APIAuth 'client access id':'signature' 155 | ``` 156 | 157 | to: 158 | 159 | ```sh 160 | Authorization = APIAuth-HMAC-DIGEST_NAME 'client access id':'signature' 161 | ``` 162 | 163 | ### ActiveResource Clients 164 | 165 | ApiAuth can transparently protect your ActiveResource communications with a 166 | single configuration line: 167 | 168 | ```ruby 169 | class MyResource < ActiveResource::Base 170 | with_api_auth(access_id, secret_key) 171 | end 172 | ``` 173 | 174 | This will automatically sign all outgoing ActiveResource requests from your app. 175 | 176 | ### Flexirest 177 | 178 | ApiAuth also works with [Flexirest](https://github.com/andyjeffries/flexirest) (used to be ActiveRestClient, but that is now unsupported) in a very similar way. 179 | Simply add this configuration to your Flexirest initializer in your app and it will automatically sign all outgoing requests. 180 | 181 | ```ruby 182 | Flexirest::Base.api_auth_credentials(@access_id, @secret_key) 183 | ``` 184 | 185 | ### Faraday 186 | 187 | ApiAuth provides a middleware for adding authentication to a Faraday connection: 188 | 189 | ```ruby 190 | require 'faraday/api_auth' 191 | Faraday.new do |f| 192 | f.request :api_auth, @access_id, @secret_key 193 | end 194 | ``` 195 | 196 | The order of middlewares is important. You should make sure api_auth is last. 197 | 198 | ## Server 199 | 200 | ApiAuth provides some built in methods to help you generate API keys for your 201 | clients as well as verifying incoming API requests. 202 | 203 | To generate a Base64 encoded API key for a client: 204 | 205 | ```ruby 206 | ApiAuth.generate_secret_key 207 | ``` 208 | 209 | To validate whether or not a request is authentic: 210 | 211 | ```ruby 212 | ApiAuth.authentic?(signed_request, secret_key) 213 | ``` 214 | 215 | The `authentic?` method uses the digest specified in the `Authorization` header. 216 | For example SHA256 for: 217 | 218 | ```sh 219 | Authorization = APIAuth-HMAC-SHA256 'client access id':'signature' 220 | ``` 221 | 222 | And by default SHA1 if the HMAC-DIGEST is not specified. 223 | 224 | If you want to force the usage of another digest method, you should pass it as an option parameter: 225 | 226 | ```ruby 227 | ApiAuth.authentic?(signed_request, secret_key, :digest => 'sha256') 228 | ``` 229 | 230 | For security, requests dated older or newer than a certain timespan are considered inauthentic. 231 | 232 | This prevents old requests from being reused in replay attacks, and also ensures requests 233 | can't be dated into the far future. 234 | 235 | The default span is 15 minutes, but you can override this: 236 | 237 | ```ruby 238 | ApiAuth.authentic?(signed_request, secret_key, :clock_skew => 60) # or 1.minute in ActiveSupport 239 | ``` 240 | 241 | If you want to sign custom headers, you can pass them as an array of strings in the options like so: 242 | 243 | ``` ruby 244 | ApiAuth.authentic?(signed_request, secret_key, headers_to_sign: %w[HTTP_HEADER_NAME]) 245 | ``` 246 | 247 | With the specified headers values being at the end of the canonical string in the same order. 248 | 249 | If your server is a Rails app, the signed request will be the `request` object. 250 | 251 | In order to obtain the secret key for the client, you first need to look up the 252 | client's access_id. ApiAuth can pull that from the request headers for you: 253 | 254 | ``` ruby 255 | ApiAuth.access_id(signed_request) 256 | ``` 257 | 258 | Once you've looked up the client's record via the access id, you can then verify 259 | whether or not the request is authentic. Typically, the access id for the client 260 | will be their record's primary key in the DB that stores the record or some other 261 | public unique identifier for the client. 262 | 263 | Here's a sample method that can be used in a `before_action` if your server is a 264 | Rails app: 265 | 266 | ``` ruby 267 | before_action :api_authenticate 268 | 269 | def api_authenticate 270 | @current_account = Account.find_by_access_id(ApiAuth.access_id(request)) 271 | head(:unauthorized) unless @current_account && ApiAuth.authentic?(request, @current_account.secret_key) 272 | end 273 | ``` 274 | 275 | ## Development 276 | 277 | ApiAuth uses bundler for gem dependencies and RSpec for testing. Developing the 278 | gem requires that you have all supported HTTP clients installed. Bundler will 279 | take care of all that for you. 280 | 281 | To run the tests: 282 | 283 | Install the dependencies for a particular Rails version by specifying a gemfile in `gemfiles` directory: 284 | 285 | ```sh 286 | BUNDLE_GEMFILE=gemfiles/rails_5.gemfile bundle install 287 | ``` 288 | 289 | Run the tests with those dependencies: 290 | 291 | ```sh 292 | BUNDLE_GEMFILE=gemfiles/rails_5.gemfile bundle exec rake 293 | ``` 294 | 295 | If you'd like to add support for additional HTTP clients, check out the already 296 | implemented drivers in `lib/api_auth/request_drivers` for reference. All of 297 | the public methods for each driver are required to be implemented by your driver. 298 | 299 | ## Authors 300 | 301 | * [Mauricio Gomes](https://github.com/mgomes) 302 | * [Kevin Glowacz](https://github.com/kjg) 303 | * [Florian Wininger](https://github.com/fwininger) 304 | 305 | ## Copyright 306 | 307 | Copyright (c) 2014 Mauricio Gomes. See LICENSE.txt for further details. 308 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler' 2 | Bundler::GemHelper.install_tasks 3 | 4 | require 'rake' 5 | require 'appraisal' 6 | require 'rspec/core' 7 | require 'rspec/core/rake_task' 8 | 9 | RSpec::Core::RakeTask.new(:spec) do |spec| 10 | spec.pattern = FileList['spec/**/*_spec.rb'] 11 | end 12 | 13 | task default: :spec 14 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 2.5.1 2 | -------------------------------------------------------------------------------- /api_auth.gemspec: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.push File.expand_path('lib', __dir__) 2 | 3 | Gem::Specification.new do |s| 4 | s.name = 'api-auth' 5 | s.summary = 'Simple HMAC authentication for your APIs' 6 | s.description = 'Full HMAC auth implementation for use in your gems and Rails apps.' 7 | s.homepage = 'https://github.com/mgomes/api_auth' 8 | s.version = File.read(File.join(File.dirname(__FILE__), 'VERSION')) 9 | s.authors = ['Mauricio Gomes'] 10 | s.email = 'mauricio@edge14.com' 11 | s.license = 'MIT' 12 | 13 | s.metadata = { 14 | 'rubygems_mfa_required' => 'true' 15 | } 16 | 17 | s.required_ruby_version = '>= 2.6.0' 18 | 19 | s.add_development_dependency 'actionpack', '>= 6.0' 20 | s.add_development_dependency 'activeresource', '>= 4.0' 21 | s.add_development_dependency 'activesupport', '>= 6.0' 22 | s.add_development_dependency 'amatch' 23 | s.add_development_dependency 'appraisal' 24 | s.add_development_dependency 'curb', '~> 0.8' 25 | s.add_development_dependency 'faraday', '>= 1.1.0' 26 | s.add_development_dependency 'grape', '~> 1.1.0' 27 | s.add_development_dependency 'http' 28 | s.add_development_dependency 'httpi' 29 | s.add_development_dependency 'multipart-post', '~> 2.0' 30 | s.add_development_dependency 'pry' 31 | s.add_development_dependency 'rake' 32 | s.add_development_dependency 'rest-client', '~> 2.0' 33 | s.add_development_dependency 'rexml' 34 | s.add_development_dependency 'rspec', '~> 3.4' 35 | s.add_development_dependency 'rubocop' 36 | 37 | s.files = `git ls-files`.split("\n") 38 | s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") 39 | s.executables = `git ls-files -- bin/*`.split("\n").map { |f| File.basename(f) } 40 | s.require_paths = ['lib'] 41 | end 42 | -------------------------------------------------------------------------------- /gemfiles/rails_60.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "actionpack", "~> 6.0" 6 | gem "activeresource", "~> 5.1" 7 | gem "activesupport", "~> 6.0" 8 | 9 | gemspec path: "../" 10 | -------------------------------------------------------------------------------- /gemfiles/rails_61.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "actionpack", "~> 6.1" 6 | gem "activeresource", "~> 5.1" 7 | gem "activesupport", "~> 6.1" 8 | 9 | gemspec path: "../" 10 | -------------------------------------------------------------------------------- /gemfiles/rails_70.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "actionpack", "~> 7.0" 6 | gem "activeresource", "~> 6.0" 7 | gem "activesupport", "~> 7.0" 8 | 9 | gemspec path: "../" 10 | -------------------------------------------------------------------------------- /lib/api-auth.rb: -------------------------------------------------------------------------------- 1 | # So you can require "api-auth" instead of "api_auth" 2 | require 'api_auth' 3 | -------------------------------------------------------------------------------- /lib/api_auth.rb: -------------------------------------------------------------------------------- 1 | require 'openssl' 2 | require 'base64' 3 | require 'time' 4 | 5 | require 'api_auth/errors' 6 | require 'api_auth/helpers' 7 | 8 | require 'api_auth/request_drivers/net_http' 9 | require 'api_auth/request_drivers/curb' 10 | require 'api_auth/request_drivers/rest_client' 11 | require 'api_auth/request_drivers/action_controller' 12 | require 'api_auth/request_drivers/grape_request' 13 | require 'api_auth/request_drivers/action_dispatch' 14 | require 'api_auth/request_drivers/rack' 15 | require 'api_auth/request_drivers/httpi' 16 | require 'api_auth/request_drivers/faraday' 17 | require 'api_auth/request_drivers/faraday_env' 18 | require 'api_auth/request_drivers/http' 19 | 20 | require 'api_auth/headers' 21 | require 'api_auth/base' 22 | require 'api_auth/railtie' 23 | -------------------------------------------------------------------------------- /lib/api_auth/base.rb: -------------------------------------------------------------------------------- 1 | # api-auth is a Ruby gem designed to be used both in your client and server 2 | # HTTP-based applications. It implements the same authentication methods (HMAC) 3 | # used by Amazon Web Services. 4 | 5 | # The gem will sign your requests on the client side and authenticate that 6 | # signature on the server side. If your server resources are implemented as a 7 | # Rails ActiveResource, it will integrate with that. It will even generate the 8 | # secret keys necessary for your clients to sign their requests. 9 | module ApiAuth 10 | class << self 11 | include Helpers 12 | 13 | # Signs an HTTP request using the client's access id and secret key. 14 | # Returns the HTTP request object with the modified headers. 15 | # 16 | # request: The request can be a Net::HTTP, ActionDispatch::Request, 17 | # Curb (Curl::Easy), RestClient object or Faraday::Request. 18 | # 19 | # access_id: The public unique identifier for the client 20 | # 21 | # secret_key: assigned secret key that is known to both parties 22 | def sign!(request, access_id, secret_key, options = {}) 23 | options = { override_http_method: nil, digest: 'sha1' }.merge(options) 24 | headers = Headers.new(request) 25 | headers.calculate_hash 26 | headers.set_date 27 | headers.sign_header auth_header(headers, access_id, secret_key, options) 28 | end 29 | 30 | # Determines if the request is authentic given the request and the client's 31 | # secret key. Returns true if the request is authentic and false otherwise. 32 | def authentic?(request, secret_key, options = {}) 33 | return false if secret_key.nil? 34 | 35 | options = { override_http_method: nil, authorize_md5: false }.merge(options) 36 | 37 | headers = Headers.new(request, authorize_md5: options[:authorize_md5]) 38 | 39 | # 900 seconds is 15 minutes 40 | clock_skew = options.fetch(:clock_skew, 900) 41 | 42 | if headers.content_hash_mismatch? 43 | false 44 | elsif !signatures_match?(headers, secret_key, options) 45 | false 46 | elsif !request_within_time_window?(headers, clock_skew) 47 | false 48 | else 49 | true 50 | end 51 | end 52 | 53 | # Returns the access id from the request's authorization header 54 | def access_id(request) 55 | headers = Headers.new(request) 56 | if match_data = parse_auth_header(headers.authorization_header) 57 | return match_data[2] 58 | end 59 | 60 | nil 61 | end 62 | 63 | # Generates a Base64 encoded, randomized secret key 64 | # 65 | # Store this key along with the access key that will be used for 66 | # authenticating the client 67 | def generate_secret_key 68 | random_bytes = OpenSSL::Random.random_bytes(512) 69 | b64_encode(Digest::SHA2.new(512).digest(random_bytes)) 70 | end 71 | 72 | private 73 | 74 | AUTH_HEADER_PATTERN = /APIAuth(?:-HMAC-(MD5|SHA(?:1|224|256|384|512)?))? ([^:]+):(.+)$/.freeze 75 | 76 | def request_within_time_window?(headers, clock_skew) 77 | Time.httpdate(headers.timestamp).utc > (Time.now.utc - clock_skew) && 78 | Time.httpdate(headers.timestamp).utc < (Time.now.utc + clock_skew) 79 | rescue ArgumentError 80 | false 81 | end 82 | 83 | def signatures_match?(headers, secret_key, options) 84 | match_data = parse_auth_header(headers.authorization_header) 85 | return false unless match_data 86 | 87 | digest = match_data[1].nil? ? 'SHA1' : match_data[1].upcase 88 | raise InvalidRequestDigest if !options[:digest].nil? && !options[:digest].casecmp(digest).zero? 89 | 90 | options = { digest: digest }.merge(options) 91 | 92 | header_sig = match_data[3] 93 | calculated_sig = hmac_signature(headers, secret_key, options) 94 | 95 | secure_equals?(header_sig, calculated_sig, secret_key) 96 | end 97 | 98 | def secure_equals?(m1, m2, key) 99 | sha1_hmac(key, m1) == sha1_hmac(key, m2) 100 | end 101 | 102 | def sha1_hmac(key, message) 103 | digest = OpenSSL::Digest.new('sha1') 104 | OpenSSL::HMAC.digest(digest, key, message) 105 | end 106 | 107 | def hmac_signature(headers, secret_key, options) 108 | canonical_string = headers.canonical_string(options[:override_http_method], options[:headers_to_sign]) 109 | digest = OpenSSL::Digest.new(options[:digest]) 110 | b64_encode(OpenSSL::HMAC.digest(digest, secret_key, canonical_string)) 111 | end 112 | 113 | def auth_header(headers, access_id, secret_key, options) 114 | hmac_string = "-HMAC-#{options[:digest].upcase}" unless options[:digest] == 'sha1' 115 | "APIAuth#{hmac_string} #{access_id}:#{hmac_signature(headers, secret_key, options)}" 116 | end 117 | 118 | def parse_auth_header(auth_header) 119 | AUTH_HEADER_PATTERN.match(auth_header) 120 | end 121 | end 122 | end 123 | -------------------------------------------------------------------------------- /lib/api_auth/errors.rb: -------------------------------------------------------------------------------- 1 | module ApiAuth 2 | # :nodoc: 3 | class ApiAuthError < StandardError; end 4 | 5 | # Raised when the HTTP request object passed is not supported 6 | class UnknownHTTPRequest < ApiAuthError; end 7 | 8 | # Raised when the client request digest is not the same as the server 9 | class InvalidRequestDigest < ApiAuthError; end 10 | end 11 | -------------------------------------------------------------------------------- /lib/api_auth/headers.rb: -------------------------------------------------------------------------------- 1 | module ApiAuth 2 | # Builds the canonical string given a request object. 3 | class Headers 4 | include RequestDrivers 5 | 6 | def initialize(request, authorize_md5: false) 7 | @original_request = request 8 | @request = initialize_request_driver(request, authorize_md5: authorize_md5) 9 | true 10 | end 11 | 12 | def initialize_request_driver(request, authorize_md5: false) 13 | new_request = 14 | case request.class.to_s 15 | when /Net::HTTP/ 16 | NetHttpRequest.new(request) 17 | when /RestClient/ 18 | RestClientRequest.new(request) 19 | when /Curl::Easy/ 20 | CurbRequest.new(request) 21 | when /ActionController::Request/ 22 | ActionControllerRequest.new(request) 23 | when /ActionController::TestRequest/ 24 | if defined?(ActionDispatch) 25 | ActionDispatchRequest.new(request) 26 | else 27 | ActionControllerRequest.new(request) 28 | end 29 | when /Grape::Request/ 30 | GrapeRequest.new(request) 31 | when /ActionDispatch::Request/ 32 | ActionDispatchRequest.new(request, authorize_md5: authorize_md5) 33 | when /ActionController::CgiRequest/ 34 | ActionControllerRequest.new(request) 35 | when /HTTPI::Request/ 36 | HttpiRequest.new(request) 37 | when /Faraday::Request/ 38 | FaradayRequest.new(request) 39 | when /Faraday::Env/ 40 | FaradayEnv.new(request) 41 | when /HTTP::Request/ 42 | HttpRequest.new(request) 43 | end 44 | 45 | return new_request if new_request 46 | return RackRequest.new(request) if request.is_a?(Rack::Request) 47 | 48 | raise UnknownHTTPRequest, "#{request.class} is not yet supported." 49 | end 50 | private :initialize_request_driver 51 | 52 | # Returns the request timestamp 53 | def timestamp 54 | @request.timestamp 55 | end 56 | 57 | def canonical_string(override_method = nil, headers_to_sign = []) 58 | request_method = override_method || @request.http_method 59 | 60 | raise ArgumentError, 'unable to determine the http method from the request, please supply an override' if request_method.nil? 61 | 62 | headers = @request.fetch_headers 63 | 64 | canonical_array = [request_method.upcase, 65 | @request.content_type, 66 | @request.content_hash, 67 | parse_uri(@request.original_uri || @request.request_uri), 68 | @request.timestamp] 69 | 70 | if headers_to_sign.is_a?(Array) && headers_to_sign.any? 71 | headers_to_sign.each { |h| canonical_array << headers[h] if headers[h].present? } 72 | end 73 | 74 | canonical_array.join(',') 75 | end 76 | 77 | # Returns the authorization header from the request's headers 78 | def authorization_header 79 | @request.authorization_header 80 | end 81 | 82 | def set_date 83 | @request.set_date if @request.timestamp.nil? 84 | end 85 | 86 | def calculate_hash 87 | @request.populate_content_hash if @request.content_hash.nil? 88 | end 89 | 90 | def content_hash_mismatch? 91 | if @request.content_hash.nil? 92 | false 93 | else 94 | @request.content_hash_mismatch? 95 | end 96 | end 97 | 98 | # Sets the request's authorization header with the passed in value. 99 | # The header should be the ApiAuth HMAC signature. 100 | # 101 | # This will return the original request object with the signed Authorization 102 | # header already in place. 103 | def sign_header(header) 104 | @request.set_auth_header header 105 | end 106 | 107 | private 108 | 109 | def parse_uri(uri) 110 | parsed_uri = URI.parse(uri) 111 | 112 | return parsed_uri.request_uri if parsed_uri.respond_to?(:request_uri) 113 | 114 | uri.empty? ? '/' : uri 115 | end 116 | end 117 | end 118 | -------------------------------------------------------------------------------- /lib/api_auth/helpers.rb: -------------------------------------------------------------------------------- 1 | module ApiAuth 2 | module Helpers # :nodoc: 3 | def b64_encode(string) 4 | Base64.strict_encode64(string) 5 | end 6 | 7 | def sha256_base64digest(string) 8 | Digest::SHA256.base64digest(string) 9 | end 10 | 11 | def md5_base64digest(string) 12 | Digest::MD5.base64digest(string) 13 | end 14 | 15 | # Capitalizes the keys of a hash 16 | def capitalize_keys(hsh) 17 | capitalized_hash = {} 18 | hsh.each_pair { |k, v| capitalized_hash[k.to_s.upcase] = v } 19 | capitalized_hash 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/api_auth/railtie.rb: -------------------------------------------------------------------------------- 1 | module ApiAuth 2 | # Integration with Rails 3 | # 4 | class Rails # :nodoc: 5 | module ControllerMethods # :nodoc: 6 | module InstanceMethods # :nodoc: 7 | def get_api_access_id_from_request 8 | ApiAuth.access_id(request) 9 | end 10 | 11 | def api_authenticated?(secret_key) 12 | ApiAuth.authentic?(request, secret_key) 13 | end 14 | end 15 | 16 | if defined?(ActiveSupport) 17 | ActiveSupport.on_load(:action_controller) do 18 | ActionController::Base.include(ControllerMethods::InstanceMethods) 19 | end 20 | end 21 | end 22 | 23 | module ActiveResourceExtension # :nodoc: 24 | module ActiveResourceApiAuth # :nodoc: 25 | def self.included(base) 26 | base.extend(ClassMethods) 27 | base.class_attribute :hmac_access_id 28 | base.class_attribute :hmac_secret_key 29 | base.class_attribute :use_hmac 30 | base.class_attribute :api_auth_options 31 | end 32 | 33 | module ClassMethods 34 | def with_api_auth(access_id, secret_key, options = {}) 35 | self.hmac_access_id = access_id 36 | self.hmac_secret_key = secret_key 37 | self.use_hmac = true 38 | self.api_auth_options = options 39 | 40 | class << self 41 | alias_method :connection_without_auth, :connection 42 | alias_method :connection, :connection_with_auth 43 | end 44 | end 45 | 46 | def connection_with_auth(refresh = false) 47 | c = connection_without_auth(refresh) 48 | c.hmac_access_id = hmac_access_id 49 | c.hmac_secret_key = hmac_secret_key 50 | c.use_hmac = use_hmac 51 | c.api_auth_options = api_auth_options 52 | c 53 | end 54 | end 55 | 56 | module InstanceMethods 57 | end 58 | end 59 | 60 | module Connection 61 | def self.included(base) 62 | base.send :alias_method, :request_without_auth, :request 63 | base.send :alias_method, :request, :request_with_auth 64 | 65 | base.class_eval do 66 | attr_accessor :hmac_secret_key, :hmac_access_id, :use_hmac, :api_auth_options 67 | end 68 | end 69 | 70 | def request_with_auth(method, path, *arguments) 71 | if use_hmac && hmac_access_id && hmac_secret_key 72 | h = arguments.last 73 | tmp = "Net::HTTP::#{method.to_s.capitalize}".constantize.new(path, h) 74 | tmp.body = arguments[0] if arguments.length > 1 75 | ApiAuth.sign!(tmp, hmac_access_id, hmac_secret_key, api_auth_options) 76 | if tmp['X-Authorization-Content-SHA256'] 77 | arguments.last['X-Authorization-Content-SHA256'] = tmp['X-Authorization-Content-SHA256'] 78 | end 79 | arguments.last['DATE'] = tmp['DATE'] 80 | arguments.last['Authorization'] = tmp['Authorization'] 81 | end 82 | 83 | request_without_auth(method, path, *arguments) 84 | end 85 | end 86 | 87 | if defined?(ActiveSupport) 88 | ActiveSupport.on_load(:active_resource) do 89 | ActiveResource::Base.include(ActiveResourceApiAuth) 90 | ActiveResource::Connection.include(Connection) 91 | end 92 | end 93 | end 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /lib/api_auth/request_drivers/action_controller.rb: -------------------------------------------------------------------------------- 1 | module ApiAuth 2 | module RequestDrivers # :nodoc: 3 | class ActionControllerRequest # :nodoc: 4 | include ApiAuth::Helpers 5 | 6 | def initialize(request, authorize_md5: false) 7 | @request = request 8 | @authorize_md5 = authorize_md5 9 | fetch_headers 10 | true 11 | end 12 | 13 | def set_auth_header(header) 14 | @request.env['Authorization'] = header 15 | fetch_headers 16 | @request 17 | end 18 | 19 | def calculated_hash 20 | body = @request.raw_post 21 | hashes = [sha256_base64digest(body)] 22 | hashes << md5_base64digest(body) if @authorize_md5 23 | hashes 24 | end 25 | 26 | def populate_content_hash 27 | return unless @request.put? || @request.post? 28 | 29 | @request.env['X-AUTHORIZATION-CONTENT-SHA256'] = calculated_hash 30 | fetch_headers 31 | end 32 | 33 | def content_hash_mismatch? 34 | if @request.put? || @request.post? 35 | !calculated_hash.include?(content_hash) 36 | else 37 | false 38 | end 39 | end 40 | 41 | def fetch_headers 42 | @headers = capitalize_keys @request.env 43 | end 44 | 45 | def http_method 46 | @request.request_method.to_s.upcase 47 | end 48 | 49 | def content_type 50 | find_header(%w[CONTENT-TYPE CONTENT_TYPE HTTP_CONTENT_TYPE]) 51 | end 52 | 53 | def content_hash 54 | headers = %w[X-AUTHORIZATION-CONTENT-SHA256 X_AUTHORIZATION_CONTENT_SHA256 HTTP_X_AUTHORIZATION_CONTENT_SHA256] 55 | headers += %w[CONTENT-MD5 CONTENT_MD5 HTTP_CONTENT_MD5] if @authorize_md5 56 | find_header(headers) 57 | end 58 | 59 | def original_uri 60 | find_header(%w[X-ORIGINAL-URI X_ORIGINAL_URI HTTP_X_ORIGINAL_URI]) 61 | end 62 | 63 | def request_uri 64 | @request.request_uri 65 | end 66 | 67 | def set_date 68 | @request.env['HTTP_DATE'] = Time.now.utc.httpdate 69 | fetch_headers 70 | end 71 | 72 | def timestamp 73 | find_header(%w[DATE HTTP_DATE]) 74 | end 75 | 76 | def authorization_header 77 | find_header %w[Authorization AUTHORIZATION HTTP_AUTHORIZATION] 78 | end 79 | 80 | private 81 | 82 | def find_header(keys) 83 | keys.map { |key| @headers[key] }.compact.first 84 | end 85 | end 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /lib/api_auth/request_drivers/action_dispatch.rb: -------------------------------------------------------------------------------- 1 | module ApiAuth 2 | module RequestDrivers # :nodoc: 3 | class ActionDispatchRequest < ActionControllerRequest # :nodoc: 4 | def request_uri 5 | @request.fullpath 6 | end 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/api_auth/request_drivers/curb.rb: -------------------------------------------------------------------------------- 1 | module ApiAuth 2 | module RequestDrivers # :nodoc: 3 | class CurbRequest # :nodoc: 4 | include ApiAuth::Helpers 5 | 6 | def initialize(request) 7 | @request = request 8 | fetch_headers 9 | true 10 | end 11 | 12 | def set_auth_header(header) 13 | @request.headers['Authorization'] = header 14 | fetch_headers 15 | @request 16 | end 17 | 18 | def populate_content_hash 19 | nil # doesn't appear to be possible 20 | end 21 | 22 | def content_hash_mismatch? 23 | false 24 | end 25 | 26 | def fetch_headers 27 | @headers = capitalize_keys @request.headers 28 | end 29 | 30 | def http_method 31 | nil # not possible to get the method at this layer 32 | end 33 | 34 | def content_type 35 | find_header(%w[CONTENT-TYPE CONTENT_TYPE HTTP_CONTENT_TYPE]) 36 | end 37 | 38 | def content_hash 39 | find_header(%w[X-AUTHORIZATION-CONTENT-SHA256]) 40 | end 41 | 42 | def original_uri 43 | find_header(%w[X-ORIGINAL-URI X_ORIGINAL_URI HTTP_X_ORIGINAL_URI]) 44 | end 45 | 46 | def request_uri 47 | @request.url 48 | end 49 | 50 | def set_date 51 | @request.headers['DATE'] = Time.now.utc.httpdate 52 | fetch_headers 53 | end 54 | 55 | def timestamp 56 | find_header(%w[DATE HTTP_DATE]) 57 | end 58 | 59 | def authorization_header 60 | find_header %w[Authorization AUTHORIZATION HTTP_AUTHORIZATION] 61 | end 62 | 63 | private 64 | 65 | def find_header(keys) 66 | keys.map { |key| @headers[key] }.compact.first 67 | end 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /lib/api_auth/request_drivers/faraday.rb: -------------------------------------------------------------------------------- 1 | module ApiAuth 2 | module RequestDrivers # :nodoc: 3 | class FaradayRequest # :nodoc: 4 | include ApiAuth::Helpers 5 | 6 | def initialize(request) 7 | @request = request 8 | fetch_headers 9 | true 10 | end 11 | 12 | def set_auth_header(header) 13 | @request.headers['Authorization'] = header 14 | fetch_headers 15 | @request 16 | end 17 | 18 | def calculated_hash 19 | body = @request.body || '' 20 | sha256_base64digest(body) 21 | end 22 | 23 | def populate_content_hash 24 | return unless %w[POST PUT].include?(@request.http_method.to_s.upcase) 25 | 26 | @request.headers['X-Authorization-Content-SHA256'] = calculated_hash 27 | fetch_headers 28 | end 29 | 30 | def content_hash_mismatch? 31 | if %w[POST PUT].include?(@request.http_method.to_s.upcase) 32 | calculated_hash != content_hash 33 | else 34 | false 35 | end 36 | end 37 | 38 | def fetch_headers 39 | @headers = capitalize_keys @request.headers 40 | end 41 | 42 | def http_method 43 | @request.http_method.to_s.upcase 44 | end 45 | 46 | def content_type 47 | find_header(%w[CONTENT-TYPE CONTENT_TYPE HTTP_CONTENT_TYPE]) 48 | end 49 | 50 | def content_hash 51 | find_header(%w[X-AUTHORIZATION-CONTENT-SHA256]) 52 | end 53 | 54 | def original_uri 55 | find_header(%w[X-ORIGINAL-URI X_ORIGINAL_URI HTTP_X_ORIGINAL_URI]) 56 | end 57 | 58 | def request_uri 59 | query_string = @request.params.to_query 60 | query_string = nil if query_string.empty? 61 | uri = URI::HTTP.new(nil, nil, nil, nil, nil, @request.path, nil, query_string, nil) 62 | uri.to_s 63 | end 64 | 65 | def set_date 66 | @request.headers['DATE'] = Time.now.utc.httpdate 67 | fetch_headers 68 | end 69 | 70 | def timestamp 71 | find_header(%w[DATE HTTP_DATE]) 72 | end 73 | 74 | def authorization_header 75 | find_header %w[Authorization AUTHORIZATION HTTP_AUTHORIZATION] 76 | end 77 | 78 | private 79 | 80 | def find_header(keys) 81 | keys.map { |key| @headers[key] }.compact.first 82 | end 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /lib/api_auth/request_drivers/faraday_env.rb: -------------------------------------------------------------------------------- 1 | module ApiAuth 2 | module RequestDrivers # :nodoc: 3 | # Internally, Faraday uses the class Faraday::Env to represent requests. The class is not meant 4 | # to be directly exposed to users, but this is what Faraday middlewares work with. See 5 | # . 6 | class FaradayEnv 7 | include ApiAuth::Helpers 8 | 9 | def initialize(env) 10 | @env = env 11 | end 12 | 13 | def set_auth_header(header) 14 | @env.request_headers['Authorization'] = header 15 | @env 16 | end 17 | 18 | def calculated_hash 19 | sha256_base64digest(body) 20 | end 21 | 22 | def populate_content_hash 23 | return unless %w[POST PUT PATCH].include?(http_method) 24 | 25 | @env.request_headers['X-Authorization-Content-SHA256'] = calculated_hash 26 | end 27 | 28 | def content_hash_mismatch? 29 | if %w[POST PUT PATCH].include?(http_method) 30 | calculated_hash != content_hash 31 | else 32 | false 33 | end 34 | end 35 | 36 | def http_method 37 | @env.method.to_s.upcase 38 | end 39 | 40 | def content_type 41 | type = find_header(%w[CONTENT-TYPE CONTENT_TYPE HTTP_CONTENT_TYPE]) 42 | 43 | # When sending a body-less POST request, the Content-Type is set at the last minute by the 44 | # Net::HTTP adapter, which states in the documentation for Net::HTTP#post: 45 | # 46 | # > You should set Content-Type: header field for POST. If no Content-Type: field given, 47 | # > this method uses “application/x-www-form-urlencoded” by default. 48 | # 49 | # The same applies to PATCH and PUT. Hopefully the other HTTP adapters behave similarly. 50 | # 51 | type ||= 'application/x-www-form-urlencoded' if %w[POST PATCH PUT].include?(http_method) 52 | 53 | type 54 | end 55 | 56 | def content_hash 57 | find_header(%w[X-AUTHORIZATION-CONTENT-SHA256]) 58 | end 59 | 60 | def original_uri 61 | find_header(%w[X-ORIGINAL-URI X_ORIGINAL_URI HTTP_X_ORIGINAL_URI]) 62 | end 63 | 64 | def request_uri 65 | @env.url.request_uri 66 | end 67 | 68 | def set_date 69 | @env.request_headers['Date'] = Time.now.utc.httpdate 70 | end 71 | 72 | def timestamp 73 | find_header(%w[DATE HTTP_DATE]) 74 | end 75 | 76 | def authorization_header 77 | find_header(%w[Authorization AUTHORIZATION HTTP_AUTHORIZATION]) 78 | end 79 | 80 | def body 81 | body_source = @env.request_body 82 | if body_source.respond_to?(:read) 83 | result = body_source.read 84 | body_source.rewind 85 | result 86 | else 87 | body_source.to_s 88 | end 89 | end 90 | 91 | def fetch_headers 92 | capitalize_keys @env.request_headers 93 | end 94 | 95 | private 96 | 97 | def find_header(keys) 98 | keys.map { |key| @env.request_headers[key] }.compact.first 99 | end 100 | end 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /lib/api_auth/request_drivers/grape_request.rb: -------------------------------------------------------------------------------- 1 | module ApiAuth 2 | module RequestDrivers # :nodoc: 3 | class GrapeRequest # :nodoc: 4 | include ApiAuth::Helpers 5 | 6 | def initialize(request) 7 | @request = request 8 | save_headers 9 | true 10 | end 11 | 12 | def set_auth_header(header) 13 | @request.env['HTTP_AUTHORIZATION'] = header 14 | save_headers # enforce update of processed_headers based on last updated headers 15 | @request 16 | end 17 | 18 | def calculated_hash 19 | body = @request.body.read 20 | @request.body.rewind 21 | sha256_base64digest(body) 22 | end 23 | 24 | def populate_content_hash 25 | return if !@request.put? && !@request.post? 26 | 27 | @request.env['HTTP_X_AUTHORIZATION_CONTENT_SHA256'] = calculated_hash 28 | save_headers 29 | end 30 | 31 | def content_hash_mismatch? 32 | if @request.put? || @request.post? 33 | calculated_hash != content_hash 34 | else 35 | false 36 | end 37 | end 38 | 39 | def fetch_headers 40 | capitalize_keys @request.env 41 | end 42 | 43 | def http_method 44 | @request.request_method.upcase 45 | end 46 | 47 | def content_type 48 | find_header %w[HTTP_X_HMAC_CONTENT_TYPE HTTP_X_CONTENT_TYPE CONTENT-TYPE CONTENT_TYPE HTTP_CONTENT_TYPE] 49 | end 50 | 51 | def content_hash 52 | find_header %w[HTTP_X_AUTHORIZATION_CONTENT_SHA256] 53 | end 54 | 55 | def original_uri 56 | find_header %w[HTTP_X_HMAC_ORIGINAL_URI HTTP_X_ORIGINAL_URI X-ORIGINAL-URI X_ORIGINAL_URI] 57 | end 58 | 59 | def request_uri 60 | @request.url 61 | end 62 | 63 | def set_date 64 | @request.env['HTTP_DATE'] = Time.now.utc.httpdate 65 | save_headers 66 | end 67 | 68 | def timestamp 69 | find_header %w[HTTP_X_HMAC_DATE HTTP_X_DATE DATE HTTP_DATE] 70 | end 71 | 72 | def authorization_header 73 | find_header %w[HTTP_X_HMAC_AUTHORIZATION HTTP_X_AUTHORIZATION Authorization AUTHORIZATION HTTP_AUTHORIZATION] 74 | end 75 | 76 | private 77 | 78 | def find_header(keys) 79 | keys.map { |key| @headers[key] }.compact.first 80 | end 81 | 82 | def save_headers 83 | @headers = fetch_headers 84 | end 85 | end 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /lib/api_auth/request_drivers/http.rb: -------------------------------------------------------------------------------- 1 | module ApiAuth 2 | module RequestDrivers # :nodoc: 3 | class HttpRequest # :nodoc: 4 | include ApiAuth::Helpers 5 | 6 | def initialize(request) 7 | @request = request 8 | end 9 | 10 | def set_auth_header(header) 11 | @request['Authorization'] = header 12 | @request 13 | end 14 | 15 | def calculated_hash 16 | sha256_base64digest(body) 17 | end 18 | 19 | def populate_content_hash 20 | return unless %w[POST PUT].include?(http_method) 21 | 22 | @request['X-Authorization-Content-SHA256'] = calculated_hash 23 | end 24 | 25 | def content_hash_mismatch? 26 | if %w[POST PUT].include?(http_method) 27 | calculated_hash != content_hash 28 | else 29 | false 30 | end 31 | end 32 | 33 | def http_method 34 | @request.verb.to_s.upcase 35 | end 36 | 37 | def content_type 38 | find_header(%w[CONTENT-TYPE CONTENT_TYPE HTTP_CONTENT_TYPE]) 39 | end 40 | 41 | def content_hash 42 | find_header(%w[X-AUTHORIZATION-CONTENT-SHA256]) 43 | end 44 | 45 | def original_uri 46 | find_header(%w[X-ORIGINAL-URI X_ORIGINAL_URI HTTP_X_ORIGINAL_URI]) 47 | end 48 | 49 | def request_uri 50 | @request.uri.request_uri 51 | end 52 | 53 | def set_date 54 | @request['Date'] = Time.now.utc.httpdate 55 | end 56 | 57 | def timestamp 58 | find_header(%w[DATE HTTP_DATE]) 59 | end 60 | 61 | def authorization_header 62 | find_header %w[Authorization AUTHORIZATION HTTP_AUTHORIZATION] 63 | end 64 | 65 | def body 66 | if body_source.respond_to?(:read) 67 | result = body_source.read 68 | body_source.rewind 69 | result 70 | else 71 | body_source.to_s 72 | end 73 | end 74 | 75 | def fetch_headers 76 | capitalize_keys @request.headers.to_h 77 | end 78 | 79 | private 80 | 81 | def find_header(keys) 82 | keys.map { |key| @request[key] }.compact.first 83 | end 84 | 85 | def body_source 86 | body = @request.body 87 | 88 | if defined?(::HTTP::Request::Body) 89 | body.respond_to?(:source) ? body.source : body.instance_variable_get(:@body) 90 | else 91 | body 92 | end 93 | end 94 | end 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /lib/api_auth/request_drivers/httpi.rb: -------------------------------------------------------------------------------- 1 | module ApiAuth 2 | module RequestDrivers # :nodoc: 3 | class HttpiRequest # :nodoc: 4 | include ApiAuth::Helpers 5 | 6 | def initialize(request) 7 | @request = request 8 | fetch_headers 9 | true 10 | end 11 | 12 | def set_auth_header(header) 13 | @request.headers['Authorization'] = header 14 | fetch_headers 15 | @request 16 | end 17 | 18 | def calculated_hash 19 | sha256_base64digest(@request.body || '') 20 | end 21 | 22 | def populate_content_hash 23 | return unless @request.body 24 | 25 | @request.headers['X-Authorization-Content-SHA256'] = calculated_hash 26 | fetch_headers 27 | end 28 | 29 | def content_hash_mismatch? 30 | if @request.body 31 | calculated_hash != content_hash 32 | else 33 | false 34 | end 35 | end 36 | 37 | def fetch_headers 38 | @headers = capitalize_keys @request.headers 39 | end 40 | 41 | def http_method 42 | nil # not possible to get the method at this layer 43 | end 44 | 45 | def content_type 46 | find_header(%w[CONTENT-TYPE CONTENT_TYPE HTTP_CONTENT_TYPE]) 47 | end 48 | 49 | def content_hash 50 | find_header(%w[X-AUTHORIZATION-CONTENT-SHA256]) 51 | end 52 | 53 | def original_uri 54 | find_header(%w[X-ORIGINAL-URI X_ORIGINAL_URI HTTP_X_ORIGINAL_URI]) 55 | end 56 | 57 | def request_uri 58 | @request.url.request_uri 59 | end 60 | 61 | def set_date 62 | @request.headers['DATE'] = Time.now.utc.httpdate 63 | fetch_headers 64 | end 65 | 66 | def timestamp 67 | find_header(%w[DATE HTTP_DATE]) 68 | end 69 | 70 | def authorization_header 71 | find_header %w[Authorization AUTHORIZATION HTTP_AUTHORIZATION] 72 | end 73 | 74 | private 75 | 76 | def find_header(keys) 77 | keys.map { |key| @headers[key] }.compact.first 78 | end 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /lib/api_auth/request_drivers/net_http.rb: -------------------------------------------------------------------------------- 1 | module ApiAuth 2 | module RequestDrivers # :nodoc: 3 | class NetHttpRequest # :nodoc: 4 | include ApiAuth::Helpers 5 | 6 | def initialize(request) 7 | @request = request 8 | @headers = fetch_headers 9 | true 10 | end 11 | 12 | def set_auth_header(header) 13 | @request['Authorization'] = header 14 | @headers = fetch_headers 15 | @request 16 | end 17 | 18 | def calculated_hash 19 | if @request.respond_to?(:body_stream) && @request.body_stream 20 | body = @request.body_stream.read 21 | @request.body_stream.rewind 22 | else 23 | body = @request.body 24 | end 25 | 26 | sha256_base64digest(body || '') 27 | end 28 | 29 | def populate_content_hash 30 | return unless @request.class::REQUEST_HAS_BODY 31 | 32 | @request['X-Authorization-Content-SHA256'] = calculated_hash 33 | end 34 | 35 | def content_hash_mismatch? 36 | if @request.class::REQUEST_HAS_BODY 37 | calculated_hash != content_hash 38 | else 39 | false 40 | end 41 | end 42 | 43 | def fetch_headers 44 | @request 45 | end 46 | 47 | def http_method 48 | @request.method.upcase 49 | end 50 | 51 | def content_type 52 | find_header(%w[CONTENT-TYPE CONTENT_TYPE HTTP_CONTENT_TYPE]) 53 | end 54 | 55 | def content_hash 56 | find_header(%w[X-Authorization-Content-SHA256]) 57 | end 58 | 59 | def original_uri 60 | find_header(%w[X-ORIGINAL-URI X_ORIGINAL_URI HTTP_X_ORIGINAL_URI]) 61 | end 62 | 63 | def request_uri 64 | @request.path 65 | end 66 | 67 | def set_date 68 | @request['DATE'] = Time.now.utc.httpdate 69 | end 70 | 71 | def timestamp 72 | find_header(%w[DATE HTTP_DATE]) 73 | end 74 | 75 | def authorization_header 76 | find_header %w[Authorization AUTHORIZATION HTTP_AUTHORIZATION] 77 | end 78 | 79 | private 80 | 81 | def find_header(keys) 82 | keys.map { |key| @headers[key] }.compact.first 83 | end 84 | end 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /lib/api_auth/request_drivers/rack.rb: -------------------------------------------------------------------------------- 1 | module ApiAuth 2 | module RequestDrivers # :nodoc: 3 | class RackRequest # :nodoc: 4 | include ApiAuth::Helpers 5 | 6 | def initialize(request) 7 | @request = request 8 | fetch_headers 9 | true 10 | end 11 | 12 | def set_auth_header(header) 13 | @request.env['Authorization'] = header 14 | fetch_headers 15 | @request 16 | end 17 | 18 | def calculated_hash 19 | if @request.body 20 | body = @request.body.read 21 | @request.body.rewind 22 | else 23 | body = '' 24 | end 25 | sha256_base64digest(body) 26 | end 27 | 28 | def populate_content_hash 29 | return unless %w[POST PUT].include?(@request.request_method) 30 | 31 | @request.env['X-Authorization-Content-SHA256'] = calculated_hash 32 | fetch_headers 33 | end 34 | 35 | def content_hash_mismatch? 36 | if %w[POST PUT].include?(@request.request_method) 37 | calculated_hash != content_hash 38 | else 39 | false 40 | end 41 | end 42 | 43 | def fetch_headers 44 | @headers = capitalize_keys @request.env 45 | end 46 | 47 | def http_method 48 | @request.request_method.upcase 49 | end 50 | 51 | def content_type 52 | find_header(%w[CONTENT-TYPE CONTENT_TYPE HTTP_CONTENT_TYPE]) 53 | end 54 | 55 | def content_hash 56 | find_header(%w[X-AUTHORIZATION-CONTENT-SHA256]) 57 | end 58 | 59 | def original_uri 60 | find_header(%w[X-ORIGINAL-URI X_ORIGINAL_URI HTTP_X_ORIGINAL_URI]) 61 | end 62 | 63 | def request_uri 64 | @request.fullpath 65 | end 66 | 67 | def set_date 68 | @request.env['DATE'] = Time.now.utc.httpdate 69 | fetch_headers 70 | end 71 | 72 | def timestamp 73 | find_header(%w[DATE HTTP_DATE]) 74 | end 75 | 76 | def authorization_header 77 | find_header %w[Authorization AUTHORIZATION HTTP_AUTHORIZATION] 78 | end 79 | 80 | private 81 | 82 | def find_header(keys) 83 | keys.map { |key| @headers[key] }.compact.first 84 | end 85 | end 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /lib/api_auth/request_drivers/rest_client.rb: -------------------------------------------------------------------------------- 1 | # give access to RestClient @processed_headers 2 | module RestClient; class Request; attr_accessor :processed_headers; end; end 3 | 4 | module ApiAuth 5 | module RequestDrivers # :nodoc: 6 | class RestClientRequest # :nodoc: 7 | include ApiAuth::Helpers 8 | 9 | def initialize(request) 10 | @request = request 11 | @headers = fetch_headers 12 | true 13 | end 14 | 15 | def set_auth_header(header) 16 | @request.headers['Authorization'] = header 17 | save_headers # enforce update of processed_headers based on last updated headers 18 | @request 19 | end 20 | 21 | def calculated_hash 22 | if @request.payload 23 | body = @request.payload.read 24 | @request.payload.instance_variable_get(:@stream).seek(0) 25 | else 26 | body = '' 27 | end 28 | sha256_base64digest(body) 29 | end 30 | 31 | def populate_content_hash 32 | return unless %w[post put].include?(@request.method.to_s) 33 | 34 | @request.headers['X-Authorization-Content-SHA256'] = calculated_hash 35 | save_headers 36 | end 37 | 38 | def content_hash_mismatch? 39 | if %w[post put].include?(@request.method.to_s) 40 | calculated_hash != content_hash 41 | else 42 | false 43 | end 44 | end 45 | 46 | def fetch_headers 47 | capitalize_keys @request.processed_headers 48 | end 49 | 50 | def http_method 51 | @request.method.to_s.upcase 52 | end 53 | 54 | def content_type 55 | find_header(%w[CONTENT-TYPE CONTENT_TYPE HTTP_CONTENT_TYPE]) 56 | end 57 | 58 | def content_hash 59 | find_header(%w[X-AUTHORIZATION-CONTENT-SHA256]) 60 | end 61 | 62 | def original_uri 63 | find_header(%w[X-ORIGINAL-URI X_ORIGINAL_URI HTTP_X_ORIGINAL_URI]) 64 | end 65 | 66 | def request_uri 67 | @request.url 68 | end 69 | 70 | def set_date 71 | @request.headers['DATE'] = Time.now.utc.httpdate 72 | save_headers 73 | end 74 | 75 | def timestamp 76 | find_header(%w[DATE HTTP_DATE]) 77 | end 78 | 79 | def authorization_header 80 | find_header %w[Authorization AUTHORIZATION HTTP_AUTHORIZATION] 81 | end 82 | 83 | private 84 | 85 | def find_header(keys) 86 | keys.map { |key| @headers[key] }.compact.first 87 | end 88 | 89 | def save_headers 90 | @request.processed_headers = @request.make_headers(@request.headers) 91 | @headers = fetch_headers 92 | end 93 | end 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /lib/faraday/api_auth.rb: -------------------------------------------------------------------------------- 1 | require_relative 'api_auth/middleware' 2 | 3 | module Faraday 4 | # Integrate ApiAuth into Faraday. 5 | module ApiAuth 6 | Faraday::Request.register_middleware(api_auth: Middleware) 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/faraday/api_auth/middleware.rb: -------------------------------------------------------------------------------- 1 | require 'api_auth' 2 | 3 | module Faraday 4 | module ApiAuth 5 | # Request middleware for Faraday. It takes the same arguments as ApiAuth.sign!. 6 | # 7 | # You will usually need to include it after the other middlewares since ApiAuth needs to hash 8 | # the final request. 9 | # 10 | # Usage: 11 | # 12 | # ```ruby 13 | # require 'faraday/api_auth' 14 | # 15 | # conn = Faraday.new do |f| 16 | # f.request :api_auth, access_id, secret_key 17 | # # Alternatively: 18 | # # f.use Faraday::ApiAuth::Middleware, access_id, secret_key 19 | # end 20 | # ``` 21 | # 22 | class Middleware < Faraday::Middleware 23 | def initialize(app, access_id, secret_key, options = {}) 24 | super(app) 25 | @access_id = access_id 26 | @secret_key = secret_key 27 | @options = options 28 | end 29 | 30 | def on_request(env) 31 | ::ApiAuth.sign!(env, @access_id, @secret_key, @options) 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /spec/api_auth_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe 'ApiAuth' do 4 | describe 'generating secret keys' do 5 | it 'should generate secret keys' do 6 | ApiAuth.generate_secret_key 7 | end 8 | 9 | it 'should generate secret keys that are 88 characters' do 10 | expect(ApiAuth.generate_secret_key.size).to be(88) 11 | end 12 | 13 | it 'should generate keys that have a Hamming Distance of at least 65' do 14 | key1 = ApiAuth.generate_secret_key 15 | key2 = ApiAuth.generate_secret_key 16 | expect(Amatch::Hamming.new(key1).match(key2)).to be > 65 17 | end 18 | end 19 | 20 | def hmac(secret_key, request, canonical_string = nil, digest = 'sha1') 21 | canonical_string ||= ApiAuth::Headers.new(request).canonical_string 22 | digest = OpenSSL::Digest.new(digest) 23 | ApiAuth.b64_encode(OpenSSL::HMAC.digest(digest, secret_key, canonical_string)) 24 | end 25 | 26 | describe '.sign!' do 27 | let(:request) { RestClient::Request.new(url: 'https://google.com', method: :get) } 28 | let(:headers) { ApiAuth::Headers.new(request) } 29 | 30 | it 'generates date header before signing' do 31 | expect(ApiAuth::Headers).to receive(:new).and_return(headers) 32 | 33 | expect(headers).to receive(:set_date).ordered 34 | expect(headers).to receive(:sign_header).ordered 35 | 36 | ApiAuth.sign!(request, 'abc', '123') 37 | end 38 | 39 | it 'generates X-Authorization-Content-SHA256 header before signing' do 40 | expect(ApiAuth::Headers).to receive(:new).and_return(headers) 41 | expect(headers).to receive(:calculate_hash).ordered 42 | expect(headers).to receive(:sign_header).ordered 43 | 44 | ApiAuth.sign!(request, 'abc', '123') 45 | end 46 | 47 | it 'returns the same request object back' do 48 | expect(ApiAuth.sign!(request, 'abc', '123')).to be request 49 | end 50 | 51 | it 'calculates the hmac_signature as expected' do 52 | ApiAuth.sign!(request, '1044', '123') 53 | signature = hmac('123', request) 54 | expect(request.headers['Authorization']).to eq("APIAuth 1044:#{signature}") 55 | end 56 | 57 | context 'when passed the hmac digest option' do 58 | let(:request) do 59 | Net::HTTP::Put.new('/resource.xml?foo=bar&bar=foo', 60 | 'content-type' => 'text/plain', 61 | 'content-hash' => '47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=', 62 | 'date' => Time.now.utc.httpdate) 63 | end 64 | 65 | let(:canonical_string) { ApiAuth::Headers.new(request).canonical_string } 66 | 67 | it 'calculates the hmac_signature with http method' do 68 | ApiAuth.sign!(request, '1044', '123', digest: 'sha256') 69 | signature = hmac('123', request, canonical_string, 'sha256') 70 | expect(request['Authorization']).to eq("APIAuth-HMAC-SHA256 1044:#{signature}") 71 | end 72 | end 73 | end 74 | 75 | describe '.authentic?' do 76 | let(:request) do 77 | Net::HTTP::Put.new('/resource.xml?foo=bar&bar=foo', 78 | 'content-type' => 'text/plain', 79 | 'X-Authorization-Content-SHA256' => '47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=', 80 | 'date' => Time.now.utc.httpdate) 81 | end 82 | 83 | let(:signed_request) do 84 | signature = hmac('123', request) 85 | request['Authorization'] = "APIAuth 1044:#{signature}" 86 | request 87 | end 88 | 89 | it 'validates that the signature in the request header matches the way we sign it' do 90 | expect(ApiAuth.authentic?(signed_request, '123')).to eq true 91 | end 92 | 93 | it 'fails to validate a non matching signature' do 94 | expect(ApiAuth.authentic?(signed_request, '456')).to eq false 95 | end 96 | 97 | it 'fails to validate non matching hash' do 98 | request['X-Authorization-Content-SHA256'] = '12345' 99 | expect(ApiAuth.authentic?(signed_request, '123')).to eq false 100 | end 101 | 102 | it 'fails to validate expired requests' do 103 | request['date'] = 16.minutes.ago.utc.httpdate 104 | expect(ApiAuth.authentic?(signed_request, '123')).to eq false 105 | end 106 | 107 | it 'fails to validate far future requests' do 108 | request['date'] = 16.minutes.from_now.utc.httpdate 109 | expect(ApiAuth.authentic?(signed_request, '123')).to eq false 110 | end 111 | 112 | it 'fails to validate if the date is invalid' do 113 | request['date'] = '٢٠١٤-٠٩-٠٨ ١٦:٣١:١٤ +٠٣٠٠' 114 | expect(ApiAuth.authentic?(signed_request, '123')).to eq false 115 | end 116 | 117 | it 'fails to validate if the request method differs' do 118 | canonical_string = ApiAuth::Headers.new(request).canonical_string('POST') 119 | signature = hmac('123', request, canonical_string) 120 | request['Authorization'] = "APIAuth 1044:#{signature}" 121 | expect(ApiAuth.authentic?(request, '123')).to eq false 122 | end 123 | 124 | context 'when passed the hmac digest option' do 125 | let(:request) do 126 | new_request = Net::HTTP::Put.new('/resource.xml?foo=bar&bar=foo', 127 | 'content-type' => 'text/plain', 128 | 'X-Authorization-Content-SHA256' => '47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=', 129 | 'date' => Time.now.utc.httpdate) 130 | canonical_string = ApiAuth::Headers.new(new_request).canonical_string 131 | signature = hmac('123', new_request, canonical_string, 'sha256') 132 | new_request['Authorization'] = "APIAuth-HMAC-#{digest} 1044:#{signature}" 133 | new_request 134 | end 135 | 136 | context 'valid request digest' do 137 | let(:digest) { 'SHA256' } 138 | 139 | context 'matching client digest' do 140 | it 'validates matching digest' do 141 | expect(ApiAuth.authentic?(request, '123', digest: 'sha256')).to eq true 142 | end 143 | end 144 | 145 | context 'different client digest' do 146 | it 'raises an exception' do 147 | expect { ApiAuth.authentic?(request, '123', digest: 'sha512') }.to raise_error(ApiAuth::InvalidRequestDigest) 148 | end 149 | end 150 | end 151 | 152 | context 'invalid request digest' do 153 | let(:digest) { 'SHA111' } 154 | 155 | it 'fails validation' do 156 | expect(ApiAuth.authentic?(request, '123', digest: 'sha111')).to eq false 157 | end 158 | end 159 | end 160 | 161 | context 'when passed the clock_skew option' do 162 | it 'fails to validate expired requests' do 163 | request['date'] = 90.seconds.ago.utc.httpdate 164 | expect(ApiAuth.authentic?(signed_request, '123', clock_skew: 60.seconds)).to eq false 165 | end 166 | 167 | it 'fails to validate far future requests' do 168 | request['date'] = 90.seconds.from_now.utc.httpdate 169 | expect(ApiAuth.authentic?(signed_request, '123', clock_skew: 60.seconds)).to eq false 170 | end 171 | end 172 | 173 | context 'when passed the headers_to_sign option' do 174 | it 'validates the request' do 175 | request['X-Forwarded-For'] = '192.168.1.1' 176 | expect(ApiAuth.authentic?(signed_request, '123', headers_to_sign: %w[HTTP_X_FORWARDED_FOR])).to eq true 177 | end 178 | end 179 | end 180 | 181 | describe '.access_id' do 182 | context 'normal APIAuth Auth header' do 183 | let(:request) do 184 | RestClient::Request.new( 185 | url: 'https://google.com', 186 | method: :get, 187 | headers: { authorization: 'APIAuth 1044:aGVsbG8gd29ybGQ=' } 188 | ) 189 | end 190 | 191 | it 'parses it from the Auth Header' do 192 | expect(ApiAuth.access_id(request)).to eq('1044') 193 | end 194 | end 195 | 196 | context 'Corporate prefixed APIAuth header' do 197 | let(:request) do 198 | RestClient::Request.new( 199 | url: 'https://google.com', 200 | method: :get, 201 | headers: { authorization: 'Corporate APIAuth 1044:aGVsbG8gd29ybGQ=' } 202 | ) 203 | end 204 | 205 | it 'parses it from the Auth Header' do 206 | expect(ApiAuth.access_id(request)).to eq('1044') 207 | end 208 | end 209 | end 210 | end 211 | -------------------------------------------------------------------------------- /spec/faraday_middleware_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'faraday/api_auth' 3 | 4 | describe Faraday::ApiAuth::Middleware do 5 | it 'adds the Authorization headers' do 6 | conn = Faraday.new('http://localhost/') do |f| 7 | f.request :api_auth, 'foo', 'secret', digest: 'sha256' 8 | f.adapter :test do |stub| 9 | stub.get('http://localhost/test') do |env| 10 | [200, {}, env.request_headers['Authorization']] 11 | end 12 | end 13 | end 14 | response = conn.get('test', nil, { 'Date' => 'Tue, 02 Aug 2022 09:29:24 GMT' }) 15 | expect(response.body).to eq 'APIAuth-HMAC-SHA256 foo:Tn/lIZ9kphcO32DwG4wFHenqBt37miDEIkA5ykLgGiQ=' 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /spec/fixtures/upload.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mgomes/api_auth/132205193cc54a9dfa17b3a8f5ddcf7aa1f4c727/spec/fixtures/upload.png -------------------------------------------------------------------------------- /spec/headers_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe ApiAuth::Headers do 4 | describe '#canonical_string' do 5 | context 'uri edge cases' do 6 | let(:request) { RestClient::Request.new(url: uri, method: :get) } 7 | subject(:headers) { described_class.new(request) } 8 | let(:uri) { '' } 9 | 10 | context 'uri with just host without /' do 11 | let(:uri) { 'https://google.com'.freeze } 12 | 13 | it 'return / as canonical string path' do 14 | expect(subject.canonical_string).to eq('GET,,,/,') 15 | end 16 | 17 | it 'does not change request url (by removing host)' do 18 | expect(request.url).to eq(uri) 19 | end 20 | end 21 | 22 | context 'uri with host and /' do 23 | let(:uri) { 'https://google.com/'.freeze } 24 | 25 | it 'return / as canonical string path' do 26 | expect(subject.canonical_string).to eq('GET,,,/,') 27 | end 28 | 29 | it 'does not change request url (by removing host)' do 30 | expect(request.url).to eq(uri) 31 | end 32 | end 33 | 34 | context 'uri has a string matching https:// in it' do 35 | let(:uri) { 'https://google.com/?redirect_to=https://www.example.com'.freeze } 36 | 37 | it 'return /?redirect_to=https://www.example.com as canonical string path' do 38 | expect(subject.canonical_string).to eq('GET,,,/?redirect_to=https://www.example.com,') 39 | end 40 | 41 | it 'does not change request url (by removing host)' do 42 | expect(request.url).to eq(uri) 43 | end 44 | end 45 | end 46 | 47 | context 'string construction' do 48 | context 'with a driver that supplies http_method' do 49 | let(:request) { RestClient::Request.new(url: 'https://google.com', method: :get) } 50 | subject(:headers) { described_class.new(request) } 51 | let(:driver) { headers.instance_variable_get('@request') } 52 | 53 | before do 54 | allow(driver).to receive(:http_method).and_return 'GET' 55 | allow(driver).to receive(:content_type).and_return 'text/html' 56 | allow(driver).to receive(:content_hash).and_return '12345' 57 | allow(driver).to receive(:request_uri).and_return '/foo' 58 | allow(driver).to receive(:timestamp).and_return 'Mon, 23 Jan 1984 03:29:56 GMT' 59 | end 60 | 61 | context 'when not passed an override' do 62 | it "constructs the canonical_string with the driver's http method" do 63 | expect(headers.canonical_string).to eq 'GET,text/html,12345,/foo,Mon, 23 Jan 1984 03:29:56 GMT' 64 | end 65 | end 66 | 67 | context 'when passed an override' do 68 | it 'constructs the canonical_string with the overridden http method' do 69 | expect(headers.canonical_string('put')).to eq 'PUT,text/html,12345,/foo,Mon, 23 Jan 1984 03:29:56 GMT' 70 | end 71 | end 72 | end 73 | 74 | context "when a driver that doesn't supply http_method" do 75 | let(:request) do 76 | Curl::Easy.new('/resource.xml?foo=bar&bar=foo') do |curl| 77 | curl.headers = { 'Content-Type' => 'text/plain' } 78 | end 79 | end 80 | subject(:headers) { described_class.new(request) } 81 | let(:driver) { headers.instance_variable_get('@request') } 82 | 83 | before do 84 | allow(driver).to receive(:http_method).and_return nil 85 | allow(driver).to receive(:content_type).and_return 'text/html' 86 | allow(driver).to receive(:content_hash).and_return '12345' 87 | allow(driver).to receive(:request_uri).and_return '/foo' 88 | allow(driver).to receive(:timestamp).and_return 'Mon, 23 Jan 1984 03:29:56 GMT' 89 | end 90 | 91 | context 'when not passed an override' do 92 | it 'raises an error' do 93 | expect { headers.canonical_string }.to raise_error(ArgumentError) 94 | end 95 | end 96 | 97 | context 'when passed an override' do 98 | it 'constructs the canonical_string with the overridden http method' do 99 | expect(headers.canonical_string('put')).to eq 'PUT,text/html,12345,/foo,Mon, 23 Jan 1984 03:29:56 GMT' 100 | end 101 | end 102 | end 103 | 104 | context "when there's a proxy server (e.g. Nginx) with rewrite rules" do 105 | let(:request) do 106 | Faraday::Request.create('GET') do |req| 107 | req.options = Faraday::RequestOptions.new(Faraday::FlatParamsEncoder) 108 | req.params = Faraday::Utils::ParamsHash.new 109 | req.url('/resource.xml?foo=bar&bar=foo') 110 | req.headers = { 'X-Original-URI' => '/api/resource.xml?foo=bar&bar=foo' } 111 | end 112 | end 113 | subject(:headers) { described_class.new(request) } 114 | let(:driver) { headers.instance_variable_get('@request') } 115 | 116 | before do 117 | allow(driver).to receive(:content_type).and_return 'text/html' 118 | allow(driver).to receive(:content_hash).and_return '12345' 119 | allow(driver).to receive(:timestamp).and_return 'Mon, 23 Jan 1984 03:29:56 GMT' 120 | end 121 | 122 | context 'the driver uses the original_uri' do 123 | it 'constructs the canonical_string with the original_uri' do 124 | expect(headers.canonical_string).to eq 'GET,text/html,12345,/api/resource.xml?foo=bar&bar=foo,Mon, 23 Jan 1984 03:29:56 GMT' 125 | end 126 | end 127 | end 128 | 129 | context 'when headers to sign are provided' do 130 | let(:request) do 131 | Faraday::Request.create('GET') do |req| 132 | req.options = Faraday::RequestOptions.new(Faraday::FlatParamsEncoder) 133 | req.params = Faraday::Utils::ParamsHash.new 134 | req.url('/resource.xml?foo=bar&bar=foo') 135 | req.headers = { 'X-Forwarded-For' => '192.168.1.1' } 136 | end 137 | end 138 | subject(:headers) { described_class.new(request) } 139 | let(:driver) { headers.instance_variable_get('@request') } 140 | 141 | before do 142 | allow(driver).to receive(:content_type).and_return 'text/html' 143 | allow(driver).to receive(:content_hash).and_return '12345' 144 | allow(driver).to receive(:timestamp).and_return 'Mon, 23 Jan 1984 03:29:56 GMT' 145 | end 146 | 147 | context 'the driver uses the original_uri' do 148 | it 'constructs the canonical_string with the original_uri' do 149 | expect(headers.canonical_string(nil, %w[X-FORWARDED-FOR])) 150 | .to eq 'GET,text/html,12345,/resource.xml?bar=foo&foo=bar,Mon, 23 Jan 1984 03:29:56 GMT,192.168.1.1' 151 | end 152 | end 153 | end 154 | end 155 | end 156 | 157 | describe '#calculate_hash' do 158 | subject(:headers) { described_class.new(request) } 159 | let(:driver) { headers.instance_variable_get('@request') } 160 | 161 | context 'no content hash already calculated' do 162 | let(:request) do 163 | RestClient::Request.new( 164 | url: 'https://google.com', 165 | method: :post, 166 | payload: "hello\nworld" 167 | ) 168 | end 169 | 170 | it 'populates the content hash header' do 171 | expect(driver).to receive(:populate_content_hash) 172 | headers.calculate_hash 173 | end 174 | end 175 | 176 | context 'hash already calculated' do 177 | let(:request) do 178 | RestClient::Request.new( 179 | url: 'https://google.com', 180 | method: :post, 181 | payload: "hello\nworld", 182 | headers: { 'X-Authorization-Content-SHA256' => 'abcd' } 183 | ) 184 | end 185 | 186 | it "doesn't populate the X-Authorization-Content-SHA256 header" do 187 | expect(driver).not_to receive(:populate_content_hash) 188 | headers.calculate_hash 189 | end 190 | end 191 | end 192 | 193 | describe '#content_hash_mismatch?' do 194 | let(:request) { RestClient::Request.new(url: 'https://google.com', method: :get) } 195 | subject(:headers) { described_class.new(request) } 196 | let(:driver) { headers.instance_variable_get('@request') } 197 | 198 | context 'when request has X-Authorization-Content-SHA256 header' do 199 | it 'asks the driver' do 200 | allow(driver).to receive(:content_hash).and_return '1234' 201 | 202 | expect(driver).to receive(:content_hash_mismatch?).and_call_original 203 | headers.content_hash_mismatch? 204 | end 205 | end 206 | 207 | context 'when request has no content hash' do 208 | it "doesn't ask the driver" do 209 | allow(driver).to receive(:content_hash).and_return nil 210 | 211 | expect(driver).not_to receive(:content_hash_mismatch?).and_call_original 212 | headers.content_hash_mismatch? 213 | end 214 | 215 | it 'returns false' do 216 | allow(driver).to receive(:content_hash).and_return nil 217 | 218 | expect(headers.content_hash_mismatch?).to be false 219 | end 220 | end 221 | end 222 | end 223 | -------------------------------------------------------------------------------- /spec/helpers_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe 'ApiAuth::Helpers' do 4 | it 'should strip the new line character on a Base64 encoding' do 5 | expect(ApiAuth.b64_encode('some string')).not_to match(/\n/) 6 | end 7 | 8 | it "should properly upcase a hash's keys" do 9 | hsh = { 'JoE' => 'rOOLz' } 10 | expect(ApiAuth.capitalize_keys(hsh)['JOE']).to eq('rOOLz') 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /spec/railtie_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe 'Rails integration' do 4 | API_KEY_STORE = { '1044' => 'l16imAXie1sRMcJODpOG7UwC1VyoqvO13jejkfpKWX4Z09W8DC9IrU23DvCwMry7pgSFW6c5S1GIfV0OY6F/vUA==' }.freeze 5 | 6 | describe 'Rails controller integration' do 7 | class ApplicationController < ActionController::Base 8 | private 9 | 10 | def require_api_auth 11 | if (access_id = get_api_access_id_from_request) && api_authenticated?(API_KEY_STORE[access_id]) 12 | return true 13 | end 14 | 15 | respond_to do |format| 16 | format.xml { render xml: 'You are unauthorized to perform this action.', status: 401 } 17 | format.json { render json: 'You are unauthorized to perform this action.', status: 401 } 18 | format.html { render plain: 'You are unauthorized to perform this action', status: 401 } 19 | end 20 | end 21 | end 22 | 23 | class TestController < ApplicationController 24 | before_action :require_api_auth, only: [:index] 25 | 26 | def self._routes 27 | ActionDispatch::Routing::RouteSet.new 28 | end 29 | 30 | def index 31 | render json: 'OK' 32 | end 33 | 34 | def public 35 | render json: 'OK' 36 | end 37 | 38 | def rescue_action(e) 39 | raise(e) 40 | end 41 | end 42 | 43 | def generated_response(request, action = :index) 44 | response = ActionDispatch::TestResponse.new 45 | controller = TestController.new 46 | controller.request = request 47 | controller.response = response 48 | controller.process(action) 49 | response 50 | end 51 | 52 | def generated_request 53 | request = if ActionController::TestRequest.respond_to?(:create) 54 | if Gem.loaded_specs['actionpack'].version < Gem::Version.new('5.1.0') 55 | ActionController::TestRequest.create 56 | else 57 | ActionController::TestRequest.create(TestController) 58 | end 59 | else 60 | ActionController::TestRequest.new 61 | end 62 | request.accept = ['application/json'] 63 | request 64 | end 65 | 66 | it 'should permit a request with properly signed headers' do 67 | request = generated_request 68 | request.env['DATE'] = Time.now.utc.httpdate 69 | ApiAuth.sign!(request, '1044', API_KEY_STORE['1044']) 70 | response = generated_response(request, :index) 71 | expect(response.code).to eq('200') 72 | end 73 | 74 | it 'should forbid a request with properly signed headers but timestamp > 15 minutes ago' do 75 | request = generated_request 76 | request.env['DATE'] = 'Mon, 23 Jan 1984 03:29:56 GMT' 77 | ApiAuth.sign!(request, '1044', API_KEY_STORE['1044']) 78 | response = generated_response(request, :index) 79 | expect(response.code).to eq('401') 80 | end 81 | 82 | it 'should forbid a request with properly signed headers but timestamp > 15 minutes in the future' do 83 | request = generated_request 84 | request.env['DATE'] = 'Mon, 23 Jan 2100 03:29:56 GMT' 85 | ApiAuth.sign!(request, '1044', API_KEY_STORE['1044']) 86 | response = generated_response(request, :index) 87 | expect(response.code).to eq('401') 88 | end 89 | 90 | it "should insert a DATE header in the request when one hasn't been specified" do 91 | request = generated_request 92 | ApiAuth.sign!(request, '1044', API_KEY_STORE['1044']) 93 | expect(request.headers['DATE']).not_to be_nil 94 | end 95 | 96 | it 'should forbid an unsigned request to a protected controller action' do 97 | request = generated_request 98 | response = generated_response(request, :index) 99 | expect(response.code).to eq('401') 100 | end 101 | 102 | it 'should forbid a request with a bogus signature' do 103 | request = generated_request 104 | request.env['Authorization'] = 'APIAuth bogus:bogus' 105 | response = generated_response(request, :index) 106 | expect(response.code).to eq('401') 107 | end 108 | 109 | it 'should allow non-protected controller actions to function as before' do 110 | request = generated_request 111 | response = generated_response(request, :public) 112 | expect(response.code).to eq('200') 113 | end 114 | end 115 | 116 | describe 'Rails ActiveResource integration' do 117 | class TestResource < ActiveResource::Base 118 | with_api_auth '1044', API_KEY_STORE['1044'] 119 | self.site = 'https://localhost/' 120 | self.format = :xml 121 | end 122 | 123 | it 'should send signed requests automagically' do 124 | timestamp = Time.parse('Mon, 23 Jan 1984 03:29:56 GMT') 125 | allow(Time).to receive(:now).at_least(1).times.and_return(timestamp) 126 | ActiveResource::HttpMock.respond_to do |mock| 127 | mock.get '/test_resources/1.xml', 128 | { 129 | 'Authorization' => 'APIAuth 1044:LZ1jujf3x1nnGR70/208WPXdUHw=', 130 | 'Accept' => 'application/xml', 131 | 'DATE' => 'Mon, 23 Jan 1984 03:29:56 GMT' 132 | }, 133 | { id: '1' }.to_xml(root: 'test_resource') 134 | end 135 | expect(ApiAuth).to receive(:sign!).with(anything, '1044', API_KEY_STORE['1044'], {}).and_call_original 136 | TestResource.find(1) 137 | end 138 | end 139 | end 140 | -------------------------------------------------------------------------------- /spec/request_drivers/action_controller_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | if defined?(ActionController::Request) 4 | 5 | describe ApiAuth::RequestDrivers::ActionControllerRequest do 6 | let(:timestamp) { Time.now.utc.httpdate } 7 | let(:content_sha256) { '47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=' } 8 | 9 | let(:request) do 10 | ActionController::Request.new( 11 | 'AUTHORIZATION' => 'APIAuth 1044:12345', 12 | 'PATH_INFO' => '/resource.xml', 13 | 'QUERY_STRING' => 'foo=bar&bar=foo', 14 | 'REQUEST_METHOD' => 'PUT', 15 | 'HTTP_X_AUTHORIZATION_CONTENT_SHA256' => content_sha256, 16 | 'CONTENT_TYPE' => 'text/plain', 17 | 'CONTENT_LENGTH' => '11', 18 | 'HTTP_DATE' => timestamp, 19 | 'rack.input' => StringIO.new("hello\nworld") 20 | ) 21 | end 22 | 23 | let(:request2) do 24 | ActionController::Request.new( 25 | 'AUTHORIZATION' => 'APIAuth 1044:12345', 26 | 'PATH_INFO' => '/resource.xml', 27 | 'QUERY_STRING' => 'foo=bar&bar=foo', 28 | 'REQUEST_METHOD' => 'PUT', 29 | 'X_AUTHORIZATION_CONTENT_SHA256' => content_sha256, 30 | 'CONTENT_TYPE' => 'text/plain', 31 | 'CONTENT_LENGTH' => '11', 32 | 'HTTP_DATE' => timestamp, 33 | 'rack.input' => StringIO.new("hello\nworld") 34 | ) 35 | end 36 | 37 | let(:request3) do 38 | ActionController::Request.new( 39 | 'AUTHORIZATION' => 'APIAuth 1044:12345', 40 | 'PATH_INFO' => '/resource.xml', 41 | 'QUERY_STRING' => 'foo=bar&bar=foo', 42 | 'REQUEST_METHOD' => 'PUT', 43 | 'X-AUTHORIZATION-CONTENT-SHA256' => content_sha256, 44 | 'CONTENT_TYPE' => 'text/plain', 45 | 'CONTENT_LENGTH' => '11', 46 | 'HTTP_DATE' => timestamp, 47 | 'rack.input' => StringIO.new("hello\nworld") 48 | ) 49 | end 50 | 51 | subject(:driven_request) { ApiAuth::RequestDrivers::ActionControllerRequest.new(request) } 52 | 53 | describe 'getting headers correctly' do 54 | it 'gets the content_type' do 55 | expect(driven_request.content_type).to eq('text/plain') 56 | end 57 | 58 | it 'gets the content_hash' do 59 | expect(driven_request.content_hash).to eq(content_sha256) 60 | end 61 | 62 | it 'gets the content_hash for request 2' do 63 | example_request = ApiAuth::RequestDrivers::ActionControllerRequest.new(request2) 64 | expect(example_request.content_hash).to eq(content_sha256) 65 | end 66 | 67 | it 'gets the content_hash for request 3' do 68 | example_request = ApiAuth::RequestDrivers::ActionControllerRequest.new(request3) 69 | expect(example_request.content_hash).to eq(content_sha256) 70 | end 71 | 72 | it 'gets the request_uri' do 73 | expect(driven_request.request_uri).to eq('/resource.xml?foo=bar&bar=foo') 74 | end 75 | 76 | it 'gets the timestamp' do 77 | expect(driven_request.timestamp).to eq(timestamp) 78 | end 79 | 80 | it 'gets the authorization_header' do 81 | expect(driven_request.authorization_header).to eq('APIAuth 1044:12345') 82 | end 83 | 84 | describe '#calculated_hash' do 85 | it 'calculates hash from the body' do 86 | expect(driven_request.calculated_hash).to eq('JsYKYdAdtYNspw/v1EpqAWYgQTyO9fJZpsVhLU9507g=') 87 | end 88 | 89 | it 'treats no body as empty string' do 90 | request.env['rack.input'] = StringIO.new 91 | request.env['CONTENT_LENGTH'] = 0 92 | expect(driven_request.calculated_hash).to eq(content_sha256) 93 | end 94 | end 95 | 96 | describe 'http_method' do 97 | context 'when put request' do 98 | let(:request) do 99 | ActionController::Request.new('REQUEST_METHOD' => 'PUT') 100 | end 101 | 102 | it 'returns upcased put' do 103 | expect(driven_request.http_method).to eq('PUT') 104 | end 105 | end 106 | 107 | context 'when get request' do 108 | let(:request) do 109 | ActionController::Request.new('REQUEST_METHOD' => 'GET') 110 | end 111 | 112 | it 'returns upcased get' do 113 | expect(driven_request.http_method).to eq('GET') 114 | end 115 | end 116 | end 117 | end 118 | 119 | describe 'setting headers correctly' do 120 | let(:request) do 121 | ActionController::Request.new( 122 | 'PATH_INFO' => '/resource.xml', 123 | 'QUERY_STRING' => 'foo=bar&bar=foo', 124 | 'REQUEST_METHOD' => 'PUT', 125 | 'CONTENT_TYPE' => 'text/plain', 126 | 'CONTENT_LENGTH' => '11', 127 | 'rack.input' => StringIO.new("hello\nworld") 128 | ) 129 | end 130 | 131 | describe '#populate_content_hash' do 132 | context 'when getting' do 133 | it "doesn't populate content hash" do 134 | request.env['REQUEST_METHOD'] = 'GET' 135 | driven_request.populate_content_hash 136 | expect(request.env['X-Authorization-Content-SHA256']).to be_nil 137 | end 138 | end 139 | 140 | context 'when posting' do 141 | it 'populates content hash' do 142 | request.env['REQUEST_METHOD'] = 'POST' 143 | driven_request.populate_content_hash 144 | expect(request.env['X-Authorization-Content-SHA256']).to eq('JsYKYdAdtYNspw/v1EpqAWYgQTyO9fJZpsVhLU9507g=') 145 | end 146 | 147 | it 'refreshes the cached headers' do 148 | driven_request.populate_content_hash 149 | expect(driven_request.content_hash).to eq('JsYKYdAdtYNspw/v1EpqAWYgQTyO9fJZpsVhLU9507g=') 150 | end 151 | end 152 | 153 | context 'when putting' do 154 | it 'populates content hash' do 155 | request.env['REQUEST_METHOD'] = 'PUT' 156 | driven_request.populate_content_hash 157 | expect(request.env['X-Authorization-Content-SHA256']).to eq('JsYKYdAdtYNspw/v1EpqAWYgQTyO9fJZpsVhLU9507g=') 158 | end 159 | 160 | it 'refreshes the cached headers' do 161 | driven_request.populate_content_hash 162 | expect(driven_request.content_hash).to eq('JsYKYdAdtYNspw/v1EpqAWYgQTyO9fJZpsVhLU9507g=') 163 | end 164 | end 165 | 166 | context 'when deleting' do 167 | it "doesn't populate content hash" do 168 | request.env['REQUEST_METHOD'] = 'DELETE' 169 | driven_request.populate_content_hash 170 | expect(request.env['X-Authorization-Content-SHA256']).to be_nil 171 | end 172 | end 173 | end 174 | 175 | describe '#set_date' do 176 | before do 177 | allow(Time).to receive_message_chain(:now, :utc, :httpdate).and_return(timestamp) 178 | end 179 | 180 | it 'sets the date header of the request' do 181 | driven_request.set_date 182 | expect(request.env['HTTP_DATE']).to eq(timestamp) 183 | end 184 | 185 | it 'refreshes the cached headers' do 186 | driven_request.set_date 187 | expect(driven_request.timestamp).to eq(timestamp) 188 | end 189 | end 190 | 191 | describe '#set_auth_header' do 192 | it 'sets the auth header' do 193 | driven_request.set_auth_header('APIAuth 1044:54321') 194 | expect(request.env['Authorization']).to eq('APIAuth 1044:54321') 195 | end 196 | end 197 | end 198 | 199 | describe 'content_hash_mismatch?' do 200 | context 'when getting' do 201 | before do 202 | request.env['REQUEST_METHOD'] = 'GET' 203 | end 204 | 205 | it 'is false' do 206 | expect(driven_request.content_hash_mismatch?).to be false 207 | end 208 | end 209 | 210 | context 'when posting' do 211 | before do 212 | request.env['REQUEST_METHOD'] = 'POST' 213 | end 214 | 215 | context 'when calculated matches sent' do 216 | before do 217 | request.env['X-Authorization-Content-SHA256'] = 'JsYKYdAdtYNspw/v1EpqAWYgQTyO9fJZpsVhLU9507g=' 218 | end 219 | 220 | it 'is false' do 221 | expect(driven_request.content_hash_mismatch?).to be false 222 | end 223 | end 224 | 225 | context "when calculated doesn't match sent" do 226 | before do 227 | request.env['X-Authorization-Content-SHA256'] = '3' 228 | end 229 | 230 | it 'is true' do 231 | expect(driven_request.content_hash_mismatch?).to be true 232 | end 233 | end 234 | end 235 | 236 | context 'when putting' do 237 | before do 238 | request.env['REQUEST_METHOD'] = 'PUT' 239 | end 240 | 241 | context 'when calculated matches sent' do 242 | before do 243 | request.env['X-Authorization-Content-SHA256'] = 'JsYKYdAdtYNspw/v1EpqAWYgQTyO9fJZpsVhLU9507g=' 244 | end 245 | 246 | it 'is false' do 247 | expect(driven_request.content_hash_mismatch?).to be false 248 | end 249 | end 250 | 251 | context "when calculated doesn't match sent" do 252 | before do 253 | request.env['X-Authorization-Content-SHA256'] = '3' 254 | end 255 | 256 | it 'is true' do 257 | expect(driven_request.content_hash_mismatch?).to be true 258 | end 259 | end 260 | end 261 | 262 | context 'when deleting' do 263 | before do 264 | request.env['REQUEST_METHOD'] = 'DELETE' 265 | end 266 | 267 | it 'is false' do 268 | expect(driven_request.content_hash_mismatch?).to be false 269 | end 270 | end 271 | end 272 | end 273 | 274 | describe 'fetch_headers' do 275 | it 'returns request headers' do 276 | expect(driven_request.fetch_headers).to include('CONTENT-TYPE' => 'text/plain') 277 | end 278 | end 279 | end 280 | -------------------------------------------------------------------------------- /spec/request_drivers/action_dispatch_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | if defined?(ActionDispatch::Request) 4 | 5 | describe ApiAuth::RequestDrivers::ActionDispatchRequest do 6 | let(:timestamp) { Time.now.utc.httpdate } 7 | let(:content_sha256) { '47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=' } 8 | let(:content_md5) { '+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=' } 9 | 10 | let(:request) do 11 | ActionDispatch::Request.new( 12 | 'AUTHORIZATION' => 'APIAuth 1044:12345', 13 | 'PATH_INFO' => '/resource.xml', 14 | 'QUERY_STRING' => 'foo=bar&bar=foo', 15 | 'REQUEST_METHOD' => 'PUT', 16 | 'HTTP_X_AUTHORIZATION_CONTENT_SHA256' => content_sha256, 17 | 'CONTENT_TYPE' => 'text/plain', 18 | 'CONTENT_LENGTH' => '11', 19 | 'HTTP_DATE' => timestamp, 20 | 'rack.input' => StringIO.new("hello\nworld") 21 | ) 22 | end 23 | 24 | let(:request2) do 25 | ActionDispatch::Request.new( 26 | 'AUTHORIZATION' => 'APIAuth 1044:12345', 27 | 'PATH_INFO' => '/resource.xml', 28 | 'QUERY_STRING' => 'foo=bar&bar=foo', 29 | 'REQUEST_METHOD' => 'PUT', 30 | 'X_AUTHORIZATION_CONTENT_SHA256' => content_sha256, 31 | 'CONTENT_TYPE' => 'text/plain', 32 | 'CONTENT_LENGTH' => '11', 33 | 'HTTP_DATE' => timestamp, 34 | 'rack.input' => StringIO.new("hello\nworld") 35 | ) 36 | end 37 | 38 | let(:request3) do 39 | ActionDispatch::Request.new( 40 | 'AUTHORIZATION' => 'APIAuth 1044:12345', 41 | 'PATH_INFO' => '/resource.xml', 42 | 'QUERY_STRING' => 'foo=bar&bar=foo', 43 | 'REQUEST_METHOD' => 'PUT', 44 | 'X-AUTHORIZATION-CONTENT-SHA256' => content_sha256, 45 | 'CONTENT_TYPE' => 'text/plain', 46 | 'CONTENT_LENGTH' => '11', 47 | 'HTTP_DATE' => timestamp, 48 | 'rack.input' => StringIO.new("hello\nworld") 49 | ) 50 | end 51 | 52 | let(:request_md5) do 53 | ActionDispatch::Request.new( 54 | 'AUTHORIZATION' => 'APIAuth 1044:12345', 55 | 'PATH_INFO' => '/resource.xml', 56 | 'QUERY_STRING' => 'foo=bar&bar=foo', 57 | 'REQUEST_METHOD' => 'PUT', 58 | 'CONTENT_MD5' => content_md5, 59 | 'CONTENT_TYPE' => 'text/plain', 60 | 'CONTENT_LENGTH' => '11', 61 | 'HTTP_DATE' => timestamp, 62 | 'rack.input' => StringIO.new("hello\nworld") 63 | ) 64 | end 65 | 66 | subject(:driven_request) { ApiAuth::RequestDrivers::ActionDispatchRequest.new(request) } 67 | subject(:driven_request_md5) do 68 | ApiAuth::RequestDrivers::ActionDispatchRequest.new(request_md5, 69 | authorize_md5: true) 70 | end 71 | subject(:driven_request_sha2_with_md5) { ApiAuth::RequestDrivers::ActionDispatchRequest.new(request, authorize_md5: true) } 72 | 73 | describe 'getting headers correctly' do 74 | it 'gets the content_type' do 75 | expect(driven_request.content_type).to eq('text/plain') 76 | end 77 | 78 | it 'gets the content_hash' do 79 | expect(driven_request.content_hash).to eq(content_sha256) 80 | end 81 | 82 | it 'gets the content_hash for request 2' do 83 | example_request = ApiAuth::RequestDrivers::ActionDispatchRequest.new(request2) 84 | expect(example_request.content_hash).to eq(content_sha256) 85 | end 86 | 87 | it 'gets the content_hash for request 3' do 88 | example_request = ApiAuth::RequestDrivers::ActionDispatchRequest.new(request3) 89 | expect(example_request.content_hash).to eq(content_sha256) 90 | end 91 | 92 | it 'gets the content_hash for request_md5' do 93 | example_request = ApiAuth::RequestDrivers::ActionDispatchRequest.new(request_md5, authorize_md5: true) 94 | expect(example_request.content_hash).to eq(content_md5) 95 | end 96 | 97 | it 'gets the request_uri' do 98 | expect(driven_request.request_uri).to eq('/resource.xml?foo=bar&bar=foo') 99 | end 100 | 101 | it 'gets the timestamp' do 102 | expect(driven_request.timestamp).to eq(timestamp) 103 | end 104 | 105 | it 'gets the authorization_header' do 106 | expect(driven_request.authorization_header).to eq('APIAuth 1044:12345') 107 | end 108 | 109 | describe '#calculated_hash' do 110 | it 'calculates hash from the body' do 111 | expect(driven_request.calculated_hash).to eq(['JsYKYdAdtYNspw/v1EpqAWYgQTyO9fJZpsVhLU9507g=']) 112 | end 113 | 114 | it 'calculates hashes from the body with md5 compatibility option' do 115 | expect(driven_request_md5.calculated_hash).to eq(%w[JsYKYdAdtYNspw/v1EpqAWYgQTyO9fJZpsVhLU9507g= kZXQvrKoieG+Be1rsZVINw==]) 116 | end 117 | 118 | it 'treats no body as empty string' do 119 | request.env['rack.input'] = StringIO.new 120 | request.env['CONTENT_LENGTH'] = 0 121 | expect(driven_request.calculated_hash).to eq(['47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=']) 122 | end 123 | end 124 | 125 | describe 'http_method' do 126 | context 'when put request' do 127 | let(:request) do 128 | ActionDispatch::Request.new('REQUEST_METHOD' => 'PUT') 129 | end 130 | 131 | it 'returns upcased put' do 132 | expect(driven_request.http_method).to eq('PUT') 133 | end 134 | end 135 | 136 | context 'when get request' do 137 | let(:request) do 138 | ActionDispatch::Request.new('REQUEST_METHOD' => 'GET') 139 | end 140 | 141 | it 'returns upcased get' do 142 | expect(driven_request.http_method).to eq('GET') 143 | end 144 | end 145 | end 146 | end 147 | 148 | describe 'setting headers correctly' do 149 | let(:request) do 150 | ActionDispatch::Request.new( 151 | 'PATH_INFO' => '/resource.xml', 152 | 'QUERY_STRING' => 'foo=bar&bar=foo', 153 | 'REQUEST_METHOD' => 'PUT', 154 | 'CONTENT_TYPE' => 'text/plain', 155 | 'CONTENT_LENGTH' => '11', 156 | 'rack.input' => StringIO.new("hello\nworld") 157 | ) 158 | end 159 | 160 | describe '#populate_content_hash' do 161 | context 'when getting' do 162 | it "doesn't populate content hash" do 163 | request.env['REQUEST_METHOD'] = 'GET' 164 | driven_request.populate_content_hash 165 | expect(request.env['X-AUTHORIZATION-CONTENT-SHA256']).to be_nil 166 | end 167 | end 168 | 169 | context 'when posting' do 170 | it 'populates content hash' do 171 | request.env['REQUEST_METHOD'] = 'POST' 172 | driven_request.populate_content_hash 173 | expect(request.env['X-AUTHORIZATION-CONTENT-SHA256']).to eq(['JsYKYdAdtYNspw/v1EpqAWYgQTyO9fJZpsVhLU9507g=']) 174 | end 175 | 176 | it 'refreshes the cached headers' do 177 | driven_request.populate_content_hash 178 | expect(driven_request.content_hash).to eq(['JsYKYdAdtYNspw/v1EpqAWYgQTyO9fJZpsVhLU9507g=']) 179 | end 180 | end 181 | 182 | context 'when putting' do 183 | it 'populates content hash' do 184 | request.env['REQUEST_METHOD'] = 'PUT' 185 | driven_request.populate_content_hash 186 | expect(request.env['X-AUTHORIZATION-CONTENT-SHA256']).to eq(['JsYKYdAdtYNspw/v1EpqAWYgQTyO9fJZpsVhLU9507g=']) 187 | end 188 | 189 | it 'refreshes the cached headers' do 190 | driven_request.populate_content_hash 191 | expect(driven_request.content_hash).to eq(['JsYKYdAdtYNspw/v1EpqAWYgQTyO9fJZpsVhLU9507g=']) 192 | end 193 | end 194 | 195 | context 'when deleting' do 196 | it "doesn't populate content hash" do 197 | request.env['REQUEST_METHOD'] = 'DELETE' 198 | driven_request.populate_content_hash 199 | expect(request.env['X-AUTHORIZATION-CONTENT-SHA256']).to be_nil 200 | end 201 | end 202 | end 203 | 204 | describe '#set_date' do 205 | before do 206 | allow(Time).to receive_message_chain(:now, :utc, :httpdate).and_return(timestamp) 207 | end 208 | 209 | it 'sets the date header of the request' do 210 | driven_request.set_date 211 | expect(request.env['HTTP_DATE']).to eq(timestamp) 212 | end 213 | 214 | it 'refreshes the cached headers' do 215 | driven_request.set_date 216 | expect(driven_request.timestamp).to eq(timestamp) 217 | end 218 | end 219 | 220 | describe '#set_auth_header' do 221 | it 'sets the auth header' do 222 | driven_request.set_auth_header('APIAuth 1044:54321') 223 | expect(request.env['Authorization']).to eq('APIAuth 1044:54321') 224 | end 225 | end 226 | end 227 | 228 | describe 'content_hash_mismatch?' do 229 | context 'when getting' do 230 | before do 231 | request.env['REQUEST_METHOD'] = 'GET' 232 | request_md5.env['REQUEST_METHOD'] = 'GET' 233 | end 234 | 235 | it 'is false' do 236 | expect(driven_request.content_hash_mismatch?).to be false 237 | end 238 | 239 | it 'is false with md5' do 240 | expect(driven_request_md5.content_hash_mismatch?).to be false 241 | end 242 | 243 | it 'is false with sha2 and md5 compatibility on' do 244 | expect(driven_request_sha2_with_md5.content_hash_mismatch?).to be false 245 | end 246 | end 247 | 248 | context 'when posting' do 249 | before do 250 | request.env['REQUEST_METHOD'] = 'POST' 251 | request_md5.env['REQUEST_METHOD'] = 'POST' 252 | end 253 | 254 | context 'when calculated matches sent' do 255 | before do 256 | request.env['X-AUTHORIZATION-CONTENT-SHA256'] = 'JsYKYdAdtYNspw/v1EpqAWYgQTyO9fJZpsVhLU9507g=' 257 | request_md5.env['CONTENT_MD5'] = 'kZXQvrKoieG+Be1rsZVINw==' 258 | end 259 | 260 | it 'is false' do 261 | expect(driven_request.content_hash_mismatch?).to be false 262 | end 263 | 264 | it 'is false with md5' do 265 | expect(driven_request_md5.content_hash_mismatch?).to be false 266 | end 267 | 268 | it 'is false with sha2 and md5 compatibility on' do 269 | expect(driven_request_sha2_with_md5.content_hash_mismatch?).to be false 270 | end 271 | end 272 | 273 | context "when calculated doesn't match sent" do 274 | before do 275 | request.env['X-AUTHORIZATION-CONTENT-SHA256'] = '3' 276 | request_md5.env['CONTENT_MD5'] = '3' 277 | end 278 | 279 | it 'is true' do 280 | expect(driven_request.content_hash_mismatch?).to be true 281 | end 282 | 283 | it 'is true with md5' do 284 | expect(driven_request.content_hash_mismatch?).to be true 285 | end 286 | 287 | it 'is true with sha2 and md5 compatibility on' do 288 | expect(driven_request_sha2_with_md5.content_hash_mismatch?).to be true 289 | end 290 | end 291 | end 292 | 293 | context 'when putting' do 294 | before do 295 | request.env['REQUEST_METHOD'] = 'PUT' 296 | request_md5.env['REQUEST_METHOD'] = 'PUT' 297 | end 298 | 299 | context 'when calculated matches sent' do 300 | before do 301 | request.env['X-AUTHORIZATION-CONTENT-SHA256'] = 'JsYKYdAdtYNspw/v1EpqAWYgQTyO9fJZpsVhLU9507g=' 302 | request_md5.env['CONTENT_MD5'] = 'kZXQvrKoieG+Be1rsZVINw==' 303 | end 304 | 305 | it 'is false' do 306 | expect(driven_request.content_hash_mismatch?).to be false 307 | end 308 | 309 | it 'is false with md5' do 310 | expect(driven_request_md5.content_hash_mismatch?).to be false 311 | end 312 | 313 | it 'is false with sha2 and md5 compatibility on' do 314 | expect(driven_request_sha2_with_md5.content_hash_mismatch?).to be false 315 | end 316 | end 317 | 318 | context "when calculated doesn't match sent" do 319 | before do 320 | request.env['X-AUTHORIZATION-CONTENT-SHA256'] = '3' 321 | request_md5.env['CONTENT_MD5'] = '3' 322 | end 323 | 324 | it 'is true' do 325 | expect(driven_request.content_hash_mismatch?).to be true 326 | end 327 | 328 | it 'is true with md5' do 329 | expect(driven_request_md5.content_hash_mismatch?).to be true 330 | end 331 | 332 | it 'is true with sha2 and md5 compatibility on' do 333 | expect(driven_request_sha2_with_md5.content_hash_mismatch?).to be true 334 | end 335 | end 336 | end 337 | 338 | context 'when deleting' do 339 | before do 340 | request.env['REQUEST_METHOD'] = 'DELETE' 341 | request_md5.env['REQUEST_METHOD'] = 'DELETE' 342 | end 343 | 344 | it 'is false' do 345 | expect(driven_request.content_hash_mismatch?).to be false 346 | end 347 | 348 | it 'is false with md5' do 349 | expect(driven_request_md5.content_hash_mismatch?).to be false 350 | end 351 | 352 | it 'is false with sha2 and md5 compatibility on' do 353 | expect(driven_request_sha2_with_md5.content_hash_mismatch?).to be false 354 | end 355 | end 356 | end 357 | 358 | describe 'fetch_headers' do 359 | it 'returns request headers' do 360 | expect(driven_request.fetch_headers).to include('CONTENT_TYPE' => 'text/plain') 361 | end 362 | end 363 | end 364 | end 365 | -------------------------------------------------------------------------------- /spec/request_drivers/curb_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe ApiAuth::RequestDrivers::CurbRequest do 4 | let(:timestamp) { Time.now.utc.httpdate } 5 | 6 | let(:request) do 7 | headers = { 8 | 'Authorization' => 'APIAuth 1044:12345', 9 | 'X-Authorization-Content-SHA256' => '47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=', 10 | 'Content-Type' => 'text/plain', 11 | 'Date' => timestamp 12 | } 13 | Curl::Easy.new('/resource.xml?foo=bar&bar=foo') do |curl| 14 | curl.headers = headers 15 | end 16 | end 17 | 18 | subject(:driven_request) { ApiAuth::RequestDrivers::CurbRequest.new(request) } 19 | 20 | describe 'getting headers correctly' do 21 | it 'gets the content_type' do 22 | expect(driven_request.content_type).to eq('text/plain') 23 | end 24 | 25 | it 'gets the content_hash' do 26 | expect(driven_request.content_hash).to eq('47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=') 27 | end 28 | 29 | it 'gets the request_uri' do 30 | expect(driven_request.request_uri).to eq('/resource.xml?foo=bar&bar=foo') 31 | end 32 | 33 | it 'gets the timestamp' do 34 | expect(driven_request.timestamp).to eq(timestamp) 35 | end 36 | 37 | it 'gets the authorization_header' do 38 | expect(driven_request.authorization_header).to eq('APIAuth 1044:12345') 39 | end 40 | 41 | describe 'http_method' do 42 | it 'is always nil' do 43 | expect(driven_request.http_method).to be_nil 44 | end 45 | end 46 | end 47 | 48 | describe 'setting headers correctly' do 49 | let(:request) do 50 | headers = { 51 | 'Content-Type' => 'text/plain' 52 | } 53 | Curl::Easy.new('/resource.xml?foo=bar&bar=foo') do |curl| 54 | curl.headers = headers 55 | end 56 | end 57 | 58 | describe '#populate_content_hash' do 59 | it 'is a no-op' do 60 | expect(driven_request.populate_content_hash).to be_nil 61 | expect(request.headers['X-Authorization-Content-SHA256']).to be_nil 62 | end 63 | end 64 | 65 | describe '#set_date' do 66 | before do 67 | allow(Time).to receive_message_chain(:now, :utc, :httpdate).and_return(timestamp) 68 | end 69 | 70 | it 'sets the date header of the request' do 71 | driven_request.set_date 72 | expect(request.headers['DATE']).to eq(timestamp) 73 | end 74 | 75 | it 'refreshes the cached headers' do 76 | driven_request.set_date 77 | expect(driven_request.timestamp).to eq(timestamp) 78 | end 79 | end 80 | 81 | describe '#set_auth_header' do 82 | it 'sets the auth header' do 83 | driven_request.set_auth_header('APIAuth 1044:54321') 84 | expect(request.headers['Authorization']).to eq('APIAuth 1044:54321') 85 | end 86 | end 87 | end 88 | 89 | describe 'content_hash_mismatch?' do 90 | it 'is always false' do 91 | expect(driven_request.content_hash_mismatch?).to be false 92 | end 93 | end 94 | 95 | describe 'fetch_headers' do 96 | it 'returns request headers' do 97 | expect(driven_request.fetch_headers).to include('CONTENT-TYPE' => 'text/plain') 98 | end 99 | end 100 | end 101 | -------------------------------------------------------------------------------- /spec/request_drivers/faraday_env_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe ApiAuth::RequestDrivers::FaradayEnv do 4 | let(:timestamp) { Time.now.utc.httpdate } 5 | 6 | let(:request) do 7 | Faraday::Env.new(verb, body, URI(uri), {}, Faraday::Utils::Headers.new(headers)) 8 | end 9 | 10 | let(:verb) { :put } 11 | let(:uri) { 'https://localhost/resource.xml?foo=bar&bar=foo' } 12 | let(:body) { "hello\nworld" } 13 | 14 | let(:headers) do 15 | { 16 | 'Authorization' => 'APIAuth 1044:12345', 17 | 'X-Authorization-Content-SHA256' => '47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=', 18 | 'content-type' => 'text/plain', 19 | 'date' => timestamp 20 | } 21 | end 22 | 23 | subject(:driven_request) { described_class.new(request) } 24 | 25 | describe 'getting headers correctly' do 26 | it 'gets the content_type' do 27 | expect(driven_request.content_type).to eq('text/plain') 28 | end 29 | 30 | context 'without Content-Type' do 31 | let(:headers) { {} } 32 | 33 | it 'defaults to url-encoded' do 34 | expect(driven_request.content_type).to eq 'application/x-www-form-urlencoded' 35 | end 36 | end 37 | 38 | it 'gets the content_hash' do 39 | expect(driven_request.content_hash).to eq('47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=') 40 | end 41 | 42 | it 'gets the request_uri' do 43 | expect(driven_request.request_uri).to eq('/resource.xml?foo=bar&bar=foo') 44 | end 45 | 46 | it 'gets the timestamp' do 47 | expect(driven_request.timestamp).to eq(timestamp) 48 | end 49 | 50 | it 'gets the authorization_header' do 51 | expect(driven_request.authorization_header).to eq('APIAuth 1044:12345') 52 | end 53 | 54 | describe '#calculated_hash' do 55 | it 'calculates hash from the body' do 56 | expect(driven_request.calculated_hash).to eq('JsYKYdAdtYNspw/v1EpqAWYgQTyO9fJZpsVhLU9507g=') 57 | expect(driven_request.body.bytesize).to eq(11) 58 | end 59 | 60 | context 'no body' do 61 | let(:body) { nil } 62 | 63 | it 'treats no body as empty string' do 64 | expect(driven_request.calculated_hash).to eq('47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=') 65 | expect(driven_request.body.bytesize).to eq(0) 66 | end 67 | end 68 | 69 | context 'multipart content' do 70 | let(:body) { File.new('spec/fixtures/upload.png') } 71 | 72 | it 'calculates correctly for multipart content' do 73 | expect(driven_request.calculated_hash).to eq('AlKDe7kjMQhuKgKuNG8I7GA93MasHcaVJkJLaUT7+dY=') 74 | expect(driven_request.body.bytesize).to eq(5112) 75 | end 76 | end 77 | end 78 | 79 | describe 'http_method' do 80 | context 'when put request' do 81 | let(:verb) { :put } 82 | 83 | it 'returns upcased put' do 84 | expect(driven_request.http_method).to eq('PUT') 85 | end 86 | end 87 | 88 | context 'when get request' do 89 | let(:verb) { :get } 90 | 91 | it 'returns upcased get' do 92 | expect(driven_request.http_method).to eq('GET') 93 | end 94 | end 95 | end 96 | end 97 | 98 | describe 'setting headers correctly' do 99 | let(:headers) do 100 | { 101 | 'content-type' => 'text/plain' 102 | } 103 | end 104 | 105 | describe '#populate_content_hash' do 106 | context 'when request type has no body' do 107 | let(:verb) { :get } 108 | 109 | it "doesn't populate content hash" do 110 | driven_request.populate_content_hash 111 | expect(request.request_headers['X-Authorization-Content-SHA256']).to be_nil 112 | end 113 | end 114 | 115 | context 'when request type has a body' do 116 | let(:verb) { :put } 117 | 118 | it 'populates content hash' do 119 | driven_request.populate_content_hash 120 | expect(request.request_headers['X-Authorization-Content-SHA256']).to eq('JsYKYdAdtYNspw/v1EpqAWYgQTyO9fJZpsVhLU9507g=') 121 | end 122 | 123 | it 'refreshes the cached headers' do 124 | driven_request.populate_content_hash 125 | expect(driven_request.content_hash).to eq('JsYKYdAdtYNspw/v1EpqAWYgQTyO9fJZpsVhLU9507g=') 126 | end 127 | end 128 | end 129 | 130 | describe '#set_date' do 131 | before do 132 | allow(Time).to receive_message_chain(:now, :utc, :httpdate).and_return(timestamp) 133 | end 134 | 135 | it 'sets the date header of the request' do 136 | driven_request.set_date 137 | expect(request.request_headers['DATE']).to eq(timestamp) 138 | end 139 | end 140 | 141 | describe '#set_auth_header' do 142 | it 'sets the auth header' do 143 | driven_request.set_auth_header('APIAuth 1044:54321') 144 | expect(request.request_headers['Authorization']).to eq('APIAuth 1044:54321') 145 | end 146 | end 147 | end 148 | 149 | describe 'content_hash_mismatch?' do 150 | context 'when request type has no body' do 151 | let(:verb) { :get } 152 | 153 | it 'is false' do 154 | expect(driven_request.content_hash_mismatch?).to be false 155 | end 156 | end 157 | 158 | context 'when request type has a body' do 159 | let(:verb) { :put } 160 | 161 | context 'when calculated matches sent' do 162 | before do 163 | request.request_headers['X-Authorization-Content-SHA256'] = 'JsYKYdAdtYNspw/v1EpqAWYgQTyO9fJZpsVhLU9507g=' 164 | end 165 | 166 | it 'is false' do 167 | expect(driven_request.content_hash_mismatch?).to be false 168 | end 169 | end 170 | 171 | context "when calculated doesn't match sent" do 172 | before do 173 | request['X-Authorization-Content-SHA256'] = '3' 174 | end 175 | 176 | it 'is true' do 177 | expect(driven_request.content_hash_mismatch?).to be true 178 | end 179 | end 180 | end 181 | end 182 | 183 | describe 'fetch_headers' do 184 | it 'returns request headers' do 185 | expect(driven_request.fetch_headers).to include('CONTENT-TYPE' => 'text/plain') 186 | end 187 | end 188 | end 189 | -------------------------------------------------------------------------------- /spec/request_drivers/faraday_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe ApiAuth::RequestDrivers::FaradayRequest do 4 | let(:timestamp) { Time.now.utc.httpdate } 5 | 6 | let(:faraday_stubs) do 7 | Faraday::Adapter::Test::Stubs.new do |stub| 8 | stub.put('/resource.xml?foo=bar&bar=foo') { [200, {}, ''] } 9 | stub.get('/resource.xml?foo=bar&bar=foo') { [200, {}, ''] } 10 | stub.put('/resource.xml') { [200, {}, ''] } 11 | end 12 | end 13 | 14 | let(:faraday_conn) do 15 | Faraday.new do |builder| 16 | builder.adapter :test, faraday_stubs 17 | end 18 | end 19 | 20 | let(:request_headers) do 21 | { 22 | 'Authorization' => 'APIAuth 1044:12345', 23 | 'X-Authorization-Content-SHA256' => '47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=', 24 | 'content-type' => 'text/plain', 25 | 'DATE' => timestamp 26 | } 27 | end 28 | 29 | let(:request) do 30 | faraday_request = nil 31 | 32 | faraday_conn.put '/resource.xml?foo=bar&bar=foo', "hello\nworld" do |request| 33 | faraday_request = request 34 | faraday_request.headers.merge!(request_headers) 35 | end 36 | 37 | faraday_request 38 | end 39 | 40 | subject(:driven_request) { ApiAuth::RequestDrivers::FaradayRequest.new(request) } 41 | 42 | describe 'getting headers correctly' do 43 | it 'gets the content_type' do 44 | expect(driven_request.content_type).to eq('text/plain') 45 | end 46 | 47 | it 'gets the content_hash' do 48 | expect(driven_request.content_hash).to eq('47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=') 49 | end 50 | 51 | it 'gets the request_uri' do 52 | expect(driven_request.request_uri).to eq('/resource.xml?bar=foo&foo=bar') 53 | end 54 | 55 | it 'gets the timestamp' do 56 | expect(driven_request.timestamp).to eq(timestamp) 57 | end 58 | 59 | it 'gets the authorization_header' do 60 | expect(driven_request.authorization_header).to eq('APIAuth 1044:12345') 61 | end 62 | 63 | describe '#calculated_hash' do 64 | it 'calculates hash from the body' do 65 | expect(driven_request.calculated_hash).to eq('JsYKYdAdtYNspw/v1EpqAWYgQTyO9fJZpsVhLU9507g=') 66 | end 67 | 68 | it 'treats no body as empty string' do 69 | request.body = nil 70 | expect(driven_request.calculated_hash).to eq('47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=') 71 | end 72 | end 73 | 74 | describe 'http_method' do 75 | context 'when put request' do 76 | let(:request) do 77 | faraday_request = nil 78 | 79 | faraday_conn.put '/resource.xml?foo=bar&bar=foo', "hello\nworld" do |request| 80 | faraday_request = request 81 | faraday_request.headers.merge!(request_headers) 82 | end 83 | 84 | faraday_request 85 | end 86 | 87 | it 'returns upcased put' do 88 | expect(driven_request.http_method).to eq('PUT') 89 | end 90 | end 91 | 92 | context 'when get request' do 93 | let(:request) do 94 | faraday_request = nil 95 | 96 | faraday_conn.get '/resource.xml?foo=bar&bar=foo' do |request| 97 | faraday_request = request 98 | faraday_request.headers.merge!(request_headers) 99 | end 100 | 101 | faraday_request 102 | end 103 | 104 | it 'returns upcased get' do 105 | expect(driven_request.http_method).to eq('GET') 106 | end 107 | end 108 | end 109 | end 110 | 111 | describe 'setting headers correctly' do 112 | let(:request_headers) do 113 | { 114 | 'content-type' => 'text/plain' 115 | } 116 | end 117 | 118 | describe '#populate_content_hash' do 119 | context 'when getting' do 120 | it "doesn't populate content hash" do 121 | request.http_method = :get 122 | driven_request.populate_content_hash 123 | expect(request.headers['X-Authorization-Content-SHA256']).to be_nil 124 | end 125 | end 126 | 127 | context 'when posting' do 128 | it 'populates content hash' do 129 | request.http_method = :post 130 | driven_request.populate_content_hash 131 | expect(request.headers['X-Authorization-Content-SHA256']).to eq('JsYKYdAdtYNspw/v1EpqAWYgQTyO9fJZpsVhLU9507g=') 132 | end 133 | 134 | it 'refreshes the cached headers' do 135 | driven_request.populate_content_hash 136 | expect(driven_request.content_hash).to eq('JsYKYdAdtYNspw/v1EpqAWYgQTyO9fJZpsVhLU9507g=') 137 | end 138 | end 139 | 140 | context 'when putting' do 141 | it 'populates content hash' do 142 | request.http_method = :put 143 | driven_request.populate_content_hash 144 | expect(request.headers['X-Authorization-Content-SHA256']).to eq('JsYKYdAdtYNspw/v1EpqAWYgQTyO9fJZpsVhLU9507g=') 145 | end 146 | 147 | it 'refreshes the cached headers' do 148 | driven_request.populate_content_hash 149 | expect(driven_request.content_hash).to eq('JsYKYdAdtYNspw/v1EpqAWYgQTyO9fJZpsVhLU9507g=') 150 | end 151 | end 152 | 153 | context 'when deleting' do 154 | it "doesn't populate content hash" do 155 | request.http_method = :delete 156 | driven_request.populate_content_hash 157 | expect(request.headers['X-Authorization-Content-SHA256']).to be_nil 158 | end 159 | end 160 | end 161 | 162 | describe '#set_date' do 163 | before do 164 | allow(Time).to receive_message_chain(:now, :utc, :httpdate).and_return(timestamp) 165 | end 166 | 167 | it 'sets the date header of the request' do 168 | driven_request.set_date 169 | expect(request.headers['DATE']).to eq(timestamp) 170 | end 171 | 172 | it 'refreshes the cached headers' do 173 | driven_request.set_date 174 | expect(driven_request.timestamp).to eq(timestamp) 175 | end 176 | end 177 | 178 | describe '#set_auth_header' do 179 | it 'sets the auth header' do 180 | driven_request.set_auth_header('APIAuth 1044:54321') 181 | expect(request.headers['Authorization']).to eq('APIAuth 1044:54321') 182 | end 183 | end 184 | end 185 | 186 | describe 'content_hash_mismatch?' do 187 | context 'when getting' do 188 | before do 189 | request.http_method = :get 190 | end 191 | 192 | it 'is false' do 193 | expect(driven_request.content_hash_mismatch?).to be false 194 | end 195 | end 196 | 197 | context 'when posting' do 198 | before do 199 | request.http_method = :post 200 | end 201 | 202 | context 'when calculated matches sent' do 203 | before do 204 | request.headers['X-Authorization-Content-SHA256'] = 'JsYKYdAdtYNspw/v1EpqAWYgQTyO9fJZpsVhLU9507g=' 205 | end 206 | 207 | it 'is false' do 208 | expect(driven_request.content_hash_mismatch?).to be false 209 | end 210 | end 211 | 212 | context "when calculated doesn't match sent" do 213 | before do 214 | request.headers['X-Authorization-Content-SHA256'] = '3' 215 | end 216 | 217 | it 'is true' do 218 | expect(driven_request.content_hash_mismatch?).to be true 219 | end 220 | end 221 | end 222 | 223 | context 'when putting' do 224 | before do 225 | request.http_method = :put 226 | end 227 | 228 | context 'when calculated matches sent' do 229 | before do 230 | request.headers['X-Authorization-Content-SHA256'] = 'JsYKYdAdtYNspw/v1EpqAWYgQTyO9fJZpsVhLU9507g=' 231 | end 232 | 233 | it 'is false' do 234 | expect(driven_request.content_hash_mismatch?).to be false 235 | end 236 | end 237 | 238 | context "when calculated doesn't match sent" do 239 | before do 240 | request.headers['X-Authorization-Content-SHA256'] = '3' 241 | end 242 | 243 | it 'is true' do 244 | expect(driven_request.content_hash_mismatch?).to be true 245 | end 246 | end 247 | end 248 | 249 | context 'when deleting' do 250 | before do 251 | request.http_method = :delete 252 | end 253 | 254 | it 'is false' do 255 | expect(driven_request.content_hash_mismatch?).to be false 256 | end 257 | end 258 | end 259 | 260 | describe 'fetch_headers' do 261 | it 'returns request headers' do 262 | expect(driven_request.fetch_headers).to include('CONTENT-TYPE' => 'text/plain') 263 | end 264 | end 265 | end 266 | -------------------------------------------------------------------------------- /spec/request_drivers/grape_request_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe ApiAuth::RequestDrivers::GrapeRequest do 4 | let(:default_method) { 'PUT' } 5 | let(:default_params) do 6 | { 'message' => "hello\nworld" } 7 | end 8 | let(:default_options) do 9 | { 10 | method: method, 11 | params: params 12 | } 13 | end 14 | let(:default_env) do 15 | Rack::MockRequest.env_for('/', options) 16 | end 17 | let(:method) { default_method } 18 | let(:params) { default_params } 19 | let(:options) { default_options.merge(request_headers) } 20 | let(:env) { default_env } 21 | 22 | let(:request) do 23 | Grape::Request.new(env) 24 | end 25 | 26 | let(:timestamp) { Time.now.utc.httpdate } 27 | let(:request_headers) do 28 | { 29 | 'HTTP_X_AUTHORIZATION' => 'APIAuth 1044:12345', 30 | 'HTTP_X_AUTHORIZATION_CONTENT_SHA256' => 'bxVSdFeR6aHBtw7+EBi5Bt8KllUZpUutOg9ChQmaSPA=', 31 | 'HTTP_X_HMAC_CONTENT_TYPE' => 'text/plain', 32 | 'HTTP_X_HMAC_DATE' => timestamp 33 | } 34 | end 35 | 36 | subject(:driven_request) { ApiAuth::RequestDrivers::GrapeRequest.new(request) } 37 | 38 | describe 'getting headers correctly' do 39 | it 'gets the content_type' do 40 | expect(driven_request.content_type).to eq('text/plain') 41 | end 42 | 43 | it 'gets the content_hash' do 44 | expect(driven_request.content_hash).to eq('bxVSdFeR6aHBtw7+EBi5Bt8KllUZpUutOg9ChQmaSPA=') 45 | end 46 | 47 | it 'gets the request_uri' do 48 | expect(driven_request.request_uri).to eq('http://example.org/') 49 | end 50 | 51 | it 'gets the timestamp' do 52 | expect(driven_request.timestamp).to eq(timestamp) 53 | end 54 | 55 | it 'gets the authorization_header' do 56 | expect(driven_request.authorization_header).to eq('APIAuth 1044:12345') 57 | end 58 | 59 | describe '#calculated_hash' do 60 | it 'calculates hash from the body' do 61 | expect(driven_request.calculated_hash).to eq('bxVSdFeR6aHBtw7+EBi5Bt8KllUZpUutOg9ChQmaSPA=') 62 | end 63 | 64 | context 'no body' do 65 | let(:params) { {} } 66 | 67 | it 'treats no body as empty string' do 68 | expect(driven_request.calculated_hash).to eq('47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=') 69 | end 70 | end 71 | end 72 | 73 | describe 'http_method' do 74 | context 'when put request' do 75 | let(:method) { 'put' } 76 | 77 | it 'returns upcased put' do 78 | expect(driven_request.http_method).to eq('PUT') 79 | end 80 | end 81 | 82 | context 'when get request' do 83 | let(:method) { 'get' } 84 | 85 | it 'returns upcased get' do 86 | expect(driven_request.http_method).to eq('GET') 87 | end 88 | end 89 | end 90 | end 91 | 92 | describe 'setting headers correctly' do 93 | let(:request_headers) do 94 | { 95 | 'HTTP_X_HMAC_CONTENT_TYPE' => 'text/plain' 96 | } 97 | end 98 | 99 | describe '#populate_content_hash' do 100 | context 'when getting' do 101 | let(:method) { 'get' } 102 | 103 | it "doesn't populate content hash" do 104 | driven_request.populate_content_hash 105 | expect(request.headers['X-Authorization-Content-Sha256']).to be_nil 106 | end 107 | end 108 | 109 | context 'when posting' do 110 | let(:method) { 'post' } 111 | 112 | it 'populates content bash' do 113 | driven_request.populate_content_hash 114 | expect(request.headers['X-Authorization-Content-Sha256']).to eq('bxVSdFeR6aHBtw7+EBi5Bt8KllUZpUutOg9ChQmaSPA=') 115 | end 116 | 117 | it 'refreshes the cached headers' do 118 | driven_request.populate_content_hash 119 | expect(driven_request.content_hash).to eq('bxVSdFeR6aHBtw7+EBi5Bt8KllUZpUutOg9ChQmaSPA=') 120 | end 121 | end 122 | 123 | context 'when putting' do 124 | let(:method) { 'put' } 125 | 126 | it 'populates content hash' do 127 | driven_request.populate_content_hash 128 | expect(request.headers['X-Authorization-Content-Sha256']).to eq('bxVSdFeR6aHBtw7+EBi5Bt8KllUZpUutOg9ChQmaSPA=') 129 | end 130 | 131 | it 'refreshes the cached headers' do 132 | driven_request.populate_content_hash 133 | expect(driven_request.content_hash).to eq('bxVSdFeR6aHBtw7+EBi5Bt8KllUZpUutOg9ChQmaSPA=') 134 | end 135 | end 136 | 137 | context 'when deleting' do 138 | let(:method) { 'delete' } 139 | 140 | it "doesn't populate content hash" do 141 | driven_request.populate_content_hash 142 | expect(request.headers['X-Authorization-Content-Sha256']).to be_nil 143 | end 144 | end 145 | end 146 | 147 | describe '#set_date' do 148 | before do 149 | allow(Time).to receive_message_chain(:now, :utc, :httpdate).and_return(timestamp) 150 | end 151 | 152 | it 'sets the date header of the request' do 153 | allow(Time).to receive_message_chain(:now, :utc, :httpdate).and_return(timestamp) 154 | driven_request.set_date 155 | expect(request.headers['Date']).to eq(timestamp) 156 | end 157 | 158 | it 'refreshes the cached headers' do 159 | driven_request.set_date 160 | expect(driven_request.timestamp).to eq(timestamp) 161 | end 162 | end 163 | 164 | describe '#set_auth_header' do 165 | it 'sets the auth header' do 166 | driven_request.set_auth_header('APIAuth 1044:54321') 167 | expect(request.headers['Authorization']).to eq('APIAuth 1044:54321') 168 | end 169 | end 170 | end 171 | 172 | describe 'content_hash_mismatch?' do 173 | context 'when getting' do 174 | let(:method) { 'get' } 175 | 176 | it 'is false' do 177 | expect(driven_request.content_hash_mismatch?).to be false 178 | end 179 | end 180 | 181 | context 'when posting' do 182 | let(:method) { 'post' } 183 | 184 | context 'when calculated matches sent' do 185 | it 'is false' do 186 | expect(driven_request.content_hash_mismatch?).to be false 187 | end 188 | end 189 | 190 | context "when calculated doesn't match sent" do 191 | let(:params) { { 'message' => 'hello only' } } 192 | 193 | it 'is true' do 194 | expect(driven_request.content_hash_mismatch?).to be true 195 | end 196 | end 197 | end 198 | 199 | context 'when putting' do 200 | let(:method) { 'put' } 201 | 202 | context 'when calculated matches sent' do 203 | it 'is false' do 204 | puts driven_request.calculated_hash 205 | expect(driven_request.content_hash_mismatch?).to be false 206 | end 207 | end 208 | 209 | context "when calculated doesn't match sent" do 210 | let(:params) { { 'message' => 'hello only' } } 211 | it 'is true' do 212 | expect(driven_request.content_hash_mismatch?).to be true 213 | end 214 | end 215 | end 216 | 217 | context 'when deleting' do 218 | let(:method) { 'delete' } 219 | 220 | it 'is false' do 221 | expect(driven_request.content_hash_mismatch?).to be false 222 | end 223 | end 224 | end 225 | 226 | describe 'authentics?' do 227 | let(:request_headers) { {} } 228 | let(:signed_request) do 229 | ApiAuth.sign!(request, '1044', '123') 230 | end 231 | 232 | context 'when getting' do 233 | let(:method) { 'get' } 234 | 235 | it 'validates that the signature in the request header matches the way we sign it' do 236 | expect(ApiAuth.authentic?(signed_request, '123')).to eq true 237 | end 238 | end 239 | 240 | context 'when posting' do 241 | let(:method) { 'post' } 242 | 243 | it 'validates that the signature in the request header matches the way we sign it' do 244 | expect(ApiAuth.authentic?(signed_request, '123')).to eq true 245 | end 246 | end 247 | 248 | context 'when putting' do 249 | let(:method) { 'put' } 250 | 251 | let(:signed_request) do 252 | ApiAuth.sign!(request, '1044', '123') 253 | end 254 | 255 | it 'validates that the signature in the request header matches the way we sign it' do 256 | expect(ApiAuth.authentic?(signed_request, '123')).to eq true 257 | end 258 | end 259 | 260 | context 'when deleting' do 261 | let(:method) { 'delete' } 262 | 263 | let(:signed_request) do 264 | ApiAuth.sign!(request, '1044', '123') 265 | end 266 | 267 | it 'validates that the signature in the request header matches the way we sign it' do 268 | expect(ApiAuth.authentic?(signed_request, '123')).to eq true 269 | end 270 | end 271 | end 272 | 273 | describe 'fetch_headers' do 274 | it 'returns request headers' do 275 | expect(driven_request.fetch_headers).to include( 276 | 'CONTENT_TYPE' => 'application/x-www-form-urlencoded' 277 | ) 278 | end 279 | end 280 | end 281 | -------------------------------------------------------------------------------- /spec/request_drivers/http_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe ApiAuth::RequestDrivers::HttpRequest do 4 | let(:timestamp) { Time.now.utc.httpdate } 5 | 6 | let(:request) do 7 | HTTP::Request.new( 8 | verb: verb, 9 | uri: uri, 10 | headers: headers, 11 | body: body 12 | ) 13 | end 14 | 15 | let(:verb) { :put } 16 | let(:uri) { 'https://localhost/resource.xml?foo=bar&bar=foo' } 17 | let(:body) { "hello\nworld" } 18 | 19 | let(:headers) do 20 | { 21 | 'Authorization' => 'APIAuth 1044:12345', 22 | 'X-Authorization-Content-SHA256' => '47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=', 23 | 'content-type' => 'text/plain', 24 | 'date' => timestamp 25 | } 26 | end 27 | 28 | subject(:driven_request) { described_class.new(request) } 29 | 30 | describe 'getting headers correctly' do 31 | it 'gets the content_type' do 32 | expect(driven_request.content_type).to eq('text/plain') 33 | end 34 | 35 | it 'gets the content_hash' do 36 | expect(driven_request.content_hash).to eq('47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=') 37 | end 38 | 39 | it 'gets the request_uri' do 40 | expect(driven_request.request_uri).to eq('/resource.xml?foo=bar&bar=foo') 41 | end 42 | 43 | it 'gets the timestamp' do 44 | expect(driven_request.timestamp).to eq(timestamp) 45 | end 46 | 47 | it 'gets the authorization_header' do 48 | expect(driven_request.authorization_header).to eq('APIAuth 1044:12345') 49 | end 50 | 51 | describe '#calculated_hash' do 52 | it 'calculates hash from the body' do 53 | expect(driven_request.calculated_hash).to eq('JsYKYdAdtYNspw/v1EpqAWYgQTyO9fJZpsVhLU9507g=') 54 | expect(driven_request.body.bytesize).to eq(11) 55 | end 56 | 57 | context 'no body' do 58 | let(:body) { nil } 59 | 60 | it 'treats no body as empty string' do 61 | expect(driven_request.calculated_hash).to eq('47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=') 62 | expect(driven_request.body.bytesize).to eq(0) 63 | end 64 | end 65 | 66 | context 'multipart content' do 67 | let(:body) { File.new('spec/fixtures/upload.png') } 68 | 69 | it 'calculates correctly for multipart content' do 70 | expect(driven_request.calculated_hash).to eq('AlKDe7kjMQhuKgKuNG8I7GA93MasHcaVJkJLaUT7+dY=') 71 | expect(driven_request.body.bytesize).to eq(5112) 72 | end 73 | end 74 | end 75 | 76 | describe 'http_method' do 77 | context 'when put request' do 78 | let(:verb) { :put } 79 | 80 | it 'returns upcased put' do 81 | expect(driven_request.http_method).to eq('PUT') 82 | end 83 | end 84 | 85 | context 'when get request' do 86 | let(:verb) { :get } 87 | 88 | it 'returns upcased get' do 89 | expect(driven_request.http_method).to eq('GET') 90 | end 91 | end 92 | end 93 | end 94 | 95 | describe 'setting headers correctly' do 96 | let(:headers) do 97 | { 98 | 'content-type' => 'text/plain' 99 | } 100 | end 101 | 102 | describe '#populate_content_hash' do 103 | context 'when request type has no body' do 104 | let(:verb) { :get } 105 | 106 | it "doesn't populate content hash" do 107 | driven_request.populate_content_hash 108 | expect(request['X-Authorization-Content-SHA256']).to be_nil 109 | end 110 | end 111 | 112 | context 'when request type has a body' do 113 | let(:verb) { :put } 114 | 115 | it 'populates content hash' do 116 | driven_request.populate_content_hash 117 | expect(request['X-Authorization-Content-SHA256']).to eq('JsYKYdAdtYNspw/v1EpqAWYgQTyO9fJZpsVhLU9507g=') 118 | end 119 | 120 | it 'refreshes the cached headers' do 121 | driven_request.populate_content_hash 122 | expect(driven_request.content_hash).to eq('JsYKYdAdtYNspw/v1EpqAWYgQTyO9fJZpsVhLU9507g=') 123 | end 124 | end 125 | end 126 | 127 | describe '#set_date' do 128 | before do 129 | allow(Time).to receive_message_chain(:now, :utc, :httpdate).and_return(timestamp) 130 | end 131 | 132 | it 'sets the date header of the request' do 133 | driven_request.set_date 134 | expect(request['DATE']).to eq(timestamp) 135 | end 136 | 137 | it 'refreshes the cached headers' do 138 | driven_request.set_date 139 | expect(driven_request.timestamp).to eq(timestamp) 140 | end 141 | end 142 | 143 | describe '#set_auth_header' do 144 | it 'sets the auth header' do 145 | driven_request.set_auth_header('APIAuth 1044:54321') 146 | expect(request['Authorization']).to eq('APIAuth 1044:54321') 147 | end 148 | end 149 | end 150 | 151 | describe 'content_hash_mismatch?' do 152 | context 'when request type has no body' do 153 | let(:verb) { :get } 154 | 155 | it 'is false' do 156 | expect(driven_request.content_hash_mismatch?).to be false 157 | end 158 | end 159 | 160 | context 'when request type has a body' do 161 | let(:verb) { :put } 162 | 163 | context 'when calculated matches sent' do 164 | before do 165 | request['X-Authorization-Content-SHA256'] = 'JsYKYdAdtYNspw/v1EpqAWYgQTyO9fJZpsVhLU9507g=' 166 | end 167 | 168 | it 'is false' do 169 | expect(driven_request.content_hash_mismatch?).to be false 170 | end 171 | end 172 | 173 | context "when calculated doesn't match sent" do 174 | before do 175 | request['X-Authorization-Content-SHA256'] = '3' 176 | end 177 | 178 | it 'is true' do 179 | expect(driven_request.content_hash_mismatch?).to be true 180 | end 181 | end 182 | end 183 | end 184 | 185 | describe 'fetch_headers' do 186 | it 'returns request headers' do 187 | expect(driven_request.fetch_headers).to include('CONTENT-TYPE' => 'text/plain') 188 | end 189 | end 190 | end 191 | -------------------------------------------------------------------------------- /spec/request_drivers/httpi_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe ApiAuth::RequestDrivers::HttpiRequest do 4 | let(:timestamp) { Time.now.utc.httpdate } 5 | 6 | let(:request) do 7 | httpi_request = HTTPI::Request.new('https://localhost/resource.xml?foo=bar&bar=foo') 8 | httpi_request.headers.merge!('Authorization' => 'APIAuth 1044:12345', 9 | 'X-Authorization-Content-SHA256' => '47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=', 10 | 'content-type' => 'text/plain', 11 | 'date' => timestamp) 12 | httpi_request.body = "hello\nworld" 13 | httpi_request 14 | end 15 | 16 | subject(:driven_request) { ApiAuth::RequestDrivers::HttpiRequest.new(request) } 17 | 18 | describe 'getting headers correctly' do 19 | it 'gets the content_type' do 20 | expect(driven_request.content_type).to eq('text/plain') 21 | end 22 | 23 | it 'gets the content_hash' do 24 | expect(driven_request.content_hash).to eq('47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=') 25 | end 26 | 27 | it 'gets the request_uri' do 28 | expect(driven_request.request_uri).to eq('/resource.xml?foo=bar&bar=foo') 29 | end 30 | 31 | it 'gets the timestamp' do 32 | expect(driven_request.timestamp).to eq(timestamp) 33 | end 34 | 35 | it 'gets the authorization_header' do 36 | expect(driven_request.authorization_header).to eq('APIAuth 1044:12345') 37 | end 38 | 39 | describe '#calculated_hash' do 40 | it 'calculates hash from the body' do 41 | expect(driven_request.calculated_hash).to eq('JsYKYdAdtYNspw/v1EpqAWYgQTyO9fJZpsVhLU9507g=') 42 | end 43 | 44 | it 'treats no body as empty string' do 45 | request.body = nil 46 | expect(driven_request.calculated_hash).to eq('47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=') 47 | end 48 | end 49 | 50 | describe 'http_method' do 51 | it 'is always nil' do 52 | expect(driven_request.http_method).to be_nil 53 | end 54 | end 55 | end 56 | 57 | describe 'setting headers correctly' do 58 | let(:request) do 59 | httpi_request = HTTPI::Request.new('https://localhost/resource.xml?foo=bar&bar=foo') 60 | httpi_request.headers['content-type'] = 'text/plain' 61 | httpi_request 62 | end 63 | 64 | describe '#populate_content_hash' do 65 | context 'when there is no content body' do 66 | before do 67 | request.body = nil 68 | end 69 | 70 | it "doesn't populate content hash" do 71 | driven_request.populate_content_hash 72 | expect(request.headers['X-Authorization-Content-SHA256']).to be_nil 73 | end 74 | end 75 | 76 | context 'when there is a content body' do 77 | before do 78 | request.body = "hello\nworld" 79 | end 80 | 81 | it 'populates content hash' do 82 | driven_request.populate_content_hash 83 | expect(request.headers['X-Authorization-Content-SHA256']).to eq('JsYKYdAdtYNspw/v1EpqAWYgQTyO9fJZpsVhLU9507g=') 84 | end 85 | 86 | it 'refreshes the cached headers' do 87 | driven_request.populate_content_hash 88 | expect(driven_request.content_hash).to eq('JsYKYdAdtYNspw/v1EpqAWYgQTyO9fJZpsVhLU9507g=') 89 | end 90 | end 91 | end 92 | 93 | describe '#set_date' do 94 | before do 95 | allow(Time).to receive_message_chain(:now, :utc, :httpdate).and_return(timestamp) 96 | end 97 | 98 | it 'sets the date header of the request' do 99 | driven_request.set_date 100 | expect(request.headers['DATE']).to eq(timestamp) 101 | end 102 | 103 | it 'refreshes the cached headers' do 104 | driven_request.set_date 105 | expect(driven_request.timestamp).to eq(timestamp) 106 | end 107 | end 108 | 109 | describe '#set_auth_header' do 110 | it 'sets the auth header' do 111 | driven_request.set_auth_header('APIAuth 1044:54321') 112 | expect(request.headers['Authorization']).to eq('APIAuth 1044:54321') 113 | end 114 | end 115 | end 116 | 117 | describe 'content_hash_mismatch?' do 118 | context 'when there is no content body' do 119 | before do 120 | request.body = nil 121 | end 122 | 123 | it 'is false' do 124 | expect(driven_request.content_hash_mismatch?).to be false 125 | end 126 | end 127 | 128 | context 'when there is a content body' do 129 | before do 130 | request.body = "hello\nworld" 131 | end 132 | 133 | context 'when calculated matches sent' do 134 | before do 135 | request.headers['X-Authorization-Content-SHA256'] = 'JsYKYdAdtYNspw/v1EpqAWYgQTyO9fJZpsVhLU9507g=' 136 | end 137 | 138 | it 'is false' do 139 | expect(driven_request.content_hash_mismatch?).to be false 140 | end 141 | end 142 | 143 | context "when calculated doesn't match sent" do 144 | before do 145 | request.headers['X-Authorization-Content-SHA256'] = '3' 146 | end 147 | 148 | it 'is true' do 149 | expect(driven_request.content_hash_mismatch?).to be true 150 | end 151 | end 152 | end 153 | end 154 | 155 | describe 'fetch_headers' do 156 | it 'returns request headers' do 157 | expect(driven_request.fetch_headers).to include('CONTENT-TYPE' => 'text/plain') 158 | end 159 | end 160 | end 161 | -------------------------------------------------------------------------------- /spec/request_drivers/net_http_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe ApiAuth::RequestDrivers::NetHttpRequest do 4 | let(:timestamp) { Time.now.utc.httpdate } 5 | 6 | let(:request_path) { '/resource.xml?foo=bar&bar=foo' } 7 | 8 | let(:request_headers) do 9 | { 10 | 'Authorization' => 'APIAuth 1044:12345', 11 | 'X-Authorization-Content-SHA256' => '47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=', 12 | 'content-type' => 'text/plain', 13 | 'date' => timestamp 14 | } 15 | end 16 | 17 | let(:request) do 18 | net_http_request = Net::HTTP::Put.new(request_path, request_headers) 19 | net_http_request.body = "hello\nworld" 20 | net_http_request 21 | end 22 | 23 | subject(:driven_request) { ApiAuth::RequestDrivers::NetHttpRequest.new(request) } 24 | 25 | describe 'getting headers correctly' do 26 | describe '#content_type' do 27 | it 'gets the content_type' do 28 | expect(driven_request.content_type).to eq('text/plain') 29 | end 30 | 31 | it 'gets multipart content_type' do 32 | request = Net::HTTP::Put::Multipart.new('/resource.xml?foo=bar&bar=foo', 33 | 'file' => UploadIO.new(File.new('spec/fixtures/upload.png'), 'image/png', 'upload.png')) 34 | driven_request = ApiAuth::RequestDrivers::NetHttpRequest.new(request) 35 | expect(driven_request.content_type).to match 'multipart/form-data; boundary=' 36 | end 37 | end 38 | 39 | it 'gets the content_hash' do 40 | expect(driven_request.content_hash).to eq('47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=') 41 | end 42 | 43 | it 'gets the request_uri' do 44 | expect(driven_request.request_uri).to eq('/resource.xml?foo=bar&bar=foo') 45 | end 46 | 47 | it 'gets the timestamp' do 48 | expect(driven_request.timestamp).to eq(timestamp) 49 | end 50 | 51 | it 'gets the authorization_header' do 52 | expect(driven_request.authorization_header).to eq('APIAuth 1044:12345') 53 | end 54 | 55 | describe '#calculated_hash' do 56 | it 'calculate content hash from the body' do 57 | expect(driven_request.calculated_hash).to eq('JsYKYdAdtYNspw/v1EpqAWYgQTyO9fJZpsVhLU9507g=') 58 | end 59 | 60 | it 'treats no body as empty string' do 61 | request.body = nil 62 | expect(driven_request.calculated_hash).to eq('47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=') 63 | end 64 | 65 | it 'calculates correctly for multipart content' do 66 | request.body = nil 67 | request.body_stream = File.new('spec/fixtures/upload.png') 68 | expect(driven_request.calculated_hash).to eq('AlKDe7kjMQhuKgKuNG8I7GA93MasHcaVJkJLaUT7+dY=') 69 | end 70 | end 71 | 72 | describe 'http_method' do 73 | context 'when put request' do 74 | let(:request) { Net::HTTP::Put.new(request_path, request_headers) } 75 | 76 | it 'returns upcased put' do 77 | expect(driven_request.http_method).to eq('PUT') 78 | end 79 | end 80 | 81 | context 'when get request' do 82 | let(:request) { Net::HTTP::Get.new(request_path, request_headers) } 83 | 84 | it 'returns upcased get' do 85 | expect(driven_request.http_method).to eq('GET') 86 | end 87 | end 88 | end 89 | end 90 | 91 | describe 'setting headers correctly' do 92 | let(:request_headers) do 93 | { 94 | 'content-type' => 'text/plain' 95 | } 96 | end 97 | 98 | let(:request) do 99 | Net::HTTP::Put.new(request_path, request_headers) 100 | end 101 | 102 | describe '#populate_content_hash' do 103 | context 'when request type has no body' do 104 | let(:request) do 105 | Net::HTTP::Get.new(request_path, request_headers) 106 | end 107 | 108 | it "doesn't populate content hash" do 109 | driven_request.populate_content_hash 110 | expect(request['X-Authorization-Content-SHA256']).to be_nil 111 | end 112 | end 113 | 114 | context 'when request type has a body' do 115 | let(:request) do 116 | net_http_request = Net::HTTP::Put.new(request_path, request_headers) 117 | net_http_request.body = "hello\nworld" 118 | net_http_request 119 | end 120 | 121 | it 'populates content hash' do 122 | driven_request.populate_content_hash 123 | expect(request['X-Authorization-Content-SHA256']).to eq('JsYKYdAdtYNspw/v1EpqAWYgQTyO9fJZpsVhLU9507g=') 124 | end 125 | 126 | it 'refreshes the cached headers' do 127 | driven_request.populate_content_hash 128 | expect(driven_request.content_hash).to eq('JsYKYdAdtYNspw/v1EpqAWYgQTyO9fJZpsVhLU9507g=') 129 | end 130 | end 131 | end 132 | 133 | describe '#set_date' do 134 | before do 135 | allow(Time).to receive_message_chain(:now, :utc, :httpdate).and_return(timestamp) 136 | end 137 | 138 | it 'sets the date header of the request' do 139 | driven_request.set_date 140 | expect(request['DATE']).to eq(timestamp) 141 | end 142 | 143 | it 'refreshes the cached headers' do 144 | driven_request.set_date 145 | expect(driven_request.timestamp).to eq(timestamp) 146 | end 147 | end 148 | 149 | describe '#set_auth_header' do 150 | it 'sets the auth header' do 151 | driven_request.set_auth_header('APIAuth 1044:54321') 152 | expect(request['Authorization']).to eq('APIAuth 1044:54321') 153 | end 154 | end 155 | end 156 | 157 | describe 'content_hash_mismatch?' do 158 | context 'when request type has no body' do 159 | let(:request) do 160 | Net::HTTP::Get.new(request_path, request_headers) 161 | end 162 | 163 | it 'is false' do 164 | expect(driven_request.content_hash_mismatch?).to be false 165 | end 166 | end 167 | 168 | context 'when request type has a body' do 169 | let(:request) do 170 | net_http_request = Net::HTTP::Put.new(request_path, request_headers) 171 | net_http_request.body = "hello\nworld" 172 | net_http_request 173 | end 174 | 175 | context 'when calculated matches sent' do 176 | before do 177 | request['X-Authorization-Content-SHA256'] = 'JsYKYdAdtYNspw/v1EpqAWYgQTyO9fJZpsVhLU9507g=' 178 | end 179 | 180 | it 'is false' do 181 | expect(driven_request.content_hash_mismatch?).to be false 182 | end 183 | end 184 | 185 | context "when calculated doesn't match sent" do 186 | before do 187 | request['X-Authorization-Content-SHA256'] = '3' 188 | end 189 | 190 | it 'is true' do 191 | expect(driven_request.content_hash_mismatch?).to be true 192 | end 193 | end 194 | end 195 | end 196 | 197 | describe 'fetch_headers' do 198 | it 'returns request headers' do 199 | expect(driven_request.fetch_headers).to include('content-type' => ['text/plain']) 200 | end 201 | end 202 | end 203 | -------------------------------------------------------------------------------- /spec/request_drivers/rack_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe ApiAuth::RequestDrivers::RackRequest do 4 | let(:timestamp) { Time.now.utc.httpdate } 5 | 6 | let(:request_path) { '/resource.xml?foo=bar&bar=foo' } 7 | 8 | let(:request_headers) do 9 | { 10 | 'Authorization' => 'APIAuth 1044:12345', 11 | 'X-Authorization-Content-SHA256' => '47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=', 12 | 'Content-Type' => 'text/plain', 13 | 'Date' => timestamp 14 | } 15 | end 16 | 17 | let(:request) do 18 | Rack::Request.new( 19 | Rack::MockRequest.env_for( 20 | request_path, 21 | method: :put, 22 | input: "hello\nworld" 23 | ).merge!(request_headers) 24 | ) 25 | end 26 | 27 | subject(:driven_request) { ApiAuth::RequestDrivers::RackRequest.new(request) } 28 | 29 | describe 'getting headers correctly' do 30 | it 'gets the content_type' do 31 | expect(driven_request.content_type).to eq('text/plain') 32 | end 33 | 34 | it 'gets the content_hash' do 35 | expect(driven_request.content_hash).to eq('47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=') 36 | end 37 | 38 | it 'gets the request_uri' do 39 | expect(driven_request.request_uri).to eq('/resource.xml?foo=bar&bar=foo') 40 | end 41 | 42 | it 'gets the timestamp' do 43 | expect(driven_request.timestamp).to eq(timestamp) 44 | end 45 | 46 | it 'gets the authorization_header' do 47 | expect(driven_request.authorization_header).to eq('APIAuth 1044:12345') 48 | end 49 | 50 | describe '#calculated_hash' do 51 | it 'calculates hash from the body' do 52 | expect(driven_request.calculated_hash).to eq('JsYKYdAdtYNspw/v1EpqAWYgQTyO9fJZpsVhLU9507g=') 53 | end 54 | 55 | it 'treats no body as empty string' do 56 | request = Rack::Request.new( 57 | Rack::MockRequest.env_for( 58 | request_path, 59 | method: :put 60 | ).merge!(request_headers) 61 | ) 62 | driven_request = ApiAuth::RequestDrivers::RackRequest.new(request) 63 | expect(driven_request.calculated_hash).to eq('47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=') 64 | end 65 | end 66 | 67 | describe 'http_method' do 68 | context 'when put request' do 69 | let(:request) do 70 | Rack::Request.new( 71 | Rack::MockRequest.env_for( 72 | request_path, 73 | method: :put 74 | ).merge!(request_headers) 75 | ) 76 | end 77 | 78 | it 'returns upcased put' do 79 | expect(driven_request.http_method).to eq('PUT') 80 | end 81 | end 82 | 83 | context 'when get request' do 84 | let(:request) do 85 | Rack::Request.new( 86 | Rack::MockRequest.env_for( 87 | request_path, 88 | method: :get 89 | ).merge!(request_headers) 90 | ) 91 | end 92 | 93 | it 'returns upcased get' do 94 | expect(driven_request.http_method).to eq('GET') 95 | end 96 | end 97 | end 98 | end 99 | 100 | describe 'setting headers correctly' do 101 | let(:request_headers) do 102 | { 103 | 'content-type' => 'text/plain' 104 | } 105 | end 106 | 107 | describe '#populate_content_hash' do 108 | context 'when getting' do 109 | let(:request) do 110 | Rack::Request.new( 111 | Rack::MockRequest.env_for( 112 | request_path, 113 | method: :get 114 | ).merge!(request_headers) 115 | ) 116 | end 117 | 118 | it "doesn't populate content hash" do 119 | driven_request.populate_content_hash 120 | expect(request.env['X-Authorization-Content-SHA256']).to be_nil 121 | end 122 | end 123 | 124 | context 'when posting' do 125 | let(:request) do 126 | Rack::Request.new( 127 | Rack::MockRequest.env_for( 128 | request_path, 129 | method: :post, 130 | input: "hello\nworld" 131 | ).merge!(request_headers) 132 | ) 133 | end 134 | 135 | it 'populates content hash' do 136 | driven_request.populate_content_hash 137 | expect(request.env['X-Authorization-Content-SHA256']).to eq('JsYKYdAdtYNspw/v1EpqAWYgQTyO9fJZpsVhLU9507g=') 138 | end 139 | 140 | it 'refreshes the cached headers' do 141 | driven_request.populate_content_hash 142 | expect(driven_request.content_hash).to eq('JsYKYdAdtYNspw/v1EpqAWYgQTyO9fJZpsVhLU9507g=') 143 | end 144 | end 145 | 146 | context 'when putting' do 147 | let(:request) do 148 | Rack::Request.new( 149 | Rack::MockRequest.env_for( 150 | request_path, 151 | method: :put, 152 | input: "hello\nworld" 153 | ).merge!(request_headers) 154 | ) 155 | end 156 | 157 | it 'populates content hash' do 158 | driven_request.populate_content_hash 159 | expect(request.env['X-Authorization-Content-SHA256']).to eq('JsYKYdAdtYNspw/v1EpqAWYgQTyO9fJZpsVhLU9507g=') 160 | end 161 | 162 | it 'refreshes the cached headers' do 163 | driven_request.populate_content_hash 164 | expect(driven_request.content_hash).to eq('JsYKYdAdtYNspw/v1EpqAWYgQTyO9fJZpsVhLU9507g=') 165 | end 166 | end 167 | 168 | context 'when deleting' do 169 | let(:request) do 170 | Rack::Request.new( 171 | Rack::MockRequest.env_for( 172 | request_path, 173 | method: :delete 174 | ).merge!(request_headers) 175 | ) 176 | end 177 | 178 | it "doesn't populate content hash" do 179 | driven_request.populate_content_hash 180 | expect(request.env['X-Authorization-Content-SHA256']).to be_nil 181 | end 182 | end 183 | end 184 | 185 | describe '#set_date' do 186 | before do 187 | allow(Time).to receive_message_chain(:now, :utc, :httpdate).and_return(timestamp) 188 | end 189 | 190 | it 'sets the date header of the request' do 191 | driven_request.set_date 192 | expect(request.env['DATE']).to eq(timestamp) 193 | end 194 | 195 | it 'refreshes the cached headers' do 196 | driven_request.set_date 197 | expect(driven_request.timestamp).to eq(timestamp) 198 | end 199 | end 200 | 201 | describe '#set_auth_header' do 202 | it 'sets the auth header' do 203 | driven_request.set_auth_header('APIAuth 1044:54321') 204 | expect(request.env['Authorization']).to eq('APIAuth 1044:54321') 205 | end 206 | end 207 | end 208 | 209 | describe 'content_hash_mismatch?' do 210 | context 'when getting' do 211 | let(:request) do 212 | Rack::Request.new( 213 | Rack::MockRequest.env_for( 214 | request_path, 215 | method: :get 216 | ).merge!(request_headers) 217 | ) 218 | end 219 | 220 | it 'is false' do 221 | expect(driven_request.content_hash_mismatch?).to be false 222 | end 223 | end 224 | 225 | context 'when posting' do 226 | let(:request) do 227 | Rack::Request.new( 228 | Rack::MockRequest.env_for( 229 | request_path, 230 | method: :post, 231 | input: "hello\nworld" 232 | ).merge!(request_headers) 233 | ) 234 | end 235 | 236 | context 'when calculated matches sent' do 237 | before do 238 | request.env['X-Authorization-Content-SHA256'] = 'JsYKYdAdtYNspw/v1EpqAWYgQTyO9fJZpsVhLU9507g=' 239 | end 240 | 241 | it 'is false' do 242 | expect(driven_request.content_hash_mismatch?).to be false 243 | end 244 | end 245 | 246 | context "when calculated doesn't match sent" do 247 | before do 248 | request.env['X-Authorization-Content-SHA256'] = '3' 249 | end 250 | 251 | it 'is true' do 252 | expect(driven_request.content_hash_mismatch?).to be true 253 | end 254 | end 255 | end 256 | 257 | context 'when putting' do 258 | let(:request) do 259 | Rack::Request.new( 260 | Rack::MockRequest.env_for( 261 | request_path, 262 | method: :put, 263 | input: "hello\nworld" 264 | ).merge!(request_headers) 265 | ) 266 | end 267 | 268 | context 'when calculated matches sent' do 269 | before do 270 | request.env['X-Authorization-Content-SHA256'] = 'JsYKYdAdtYNspw/v1EpqAWYgQTyO9fJZpsVhLU9507g=' 271 | end 272 | 273 | it 'is false' do 274 | expect(driven_request.content_hash_mismatch?).to be false 275 | end 276 | end 277 | 278 | context "when calculated doesn't match sent" do 279 | before do 280 | request.env['X-Authorization-Content-SHA256'] = '3' 281 | end 282 | 283 | it 'is true' do 284 | expect(driven_request.content_hash_mismatch?).to be true 285 | end 286 | end 287 | end 288 | 289 | context 'when deleting' do 290 | let(:request) do 291 | Rack::Request.new( 292 | Rack::MockRequest.env_for( 293 | request_path, 294 | method: :delete 295 | ).merge!(request_headers) 296 | ) 297 | end 298 | 299 | it 'is false' do 300 | expect(driven_request.content_hash_mismatch?).to be false 301 | end 302 | end 303 | end 304 | 305 | describe 'fetch_headers' do 306 | it 'returns request headers' do 307 | expect(driven_request.fetch_headers).to include('CONTENT-TYPE' => 'text/plain') 308 | end 309 | end 310 | end 311 | -------------------------------------------------------------------------------- /spec/request_drivers/rest_client_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe ApiAuth::RequestDrivers::RestClientRequest do 4 | let(:timestamp) { Time.now.utc.httpdate } 5 | 6 | let(:request_path) { 'https://localhost/resource.xml?foo=bar&bar=foo' } 7 | 8 | let(:request_headers) do 9 | { 10 | 'Authorization' => 'APIAuth 1044:12345', 11 | 'X-Authorization-Content-SHA256' => '47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=', 12 | 'Content-Type' => 'text/plain', 13 | 'Date' => timestamp 14 | } 15 | end 16 | 17 | let(:request) do 18 | RestClient::Request.new( 19 | url: 'https://localhost/resource.xml?foo=bar&bar=foo', 20 | headers: request_headers, 21 | method: :put, 22 | payload: "hello\nworld" 23 | ) 24 | end 25 | 26 | subject(:driven_request) { ApiAuth::RequestDrivers::RestClientRequest.new(request) } 27 | 28 | describe 'getting headers correctly' do 29 | it 'gets the content_type' do 30 | expect(driven_request.content_type).to eq('text/plain') 31 | end 32 | 33 | it 'gets the content_hash' do 34 | expect(driven_request.content_hash).to eq('47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=') 35 | end 36 | 37 | it 'gets the request_uri' do 38 | expect(driven_request.request_uri).to eq('https://localhost/resource.xml?foo=bar&bar=foo') 39 | end 40 | 41 | it 'gets the timestamp' do 42 | expect(driven_request.timestamp).to eq(timestamp) 43 | end 44 | 45 | it 'gets the authorization_header' do 46 | expect(driven_request.authorization_header).to eq('APIAuth 1044:12345') 47 | end 48 | 49 | describe '#calculated_hash' do 50 | it 'calculates hash from the body' do 51 | expect(driven_request.calculated_hash).to eq('JsYKYdAdtYNspw/v1EpqAWYgQTyO9fJZpsVhLU9507g=') 52 | end 53 | 54 | it 'treats no body as empty string' do 55 | request = RestClient::Request.new( 56 | url: 'https://localhost/resource.xml?foo=bar&bar=foo', 57 | headers: request_headers, 58 | method: :put 59 | ) 60 | driven_request = ApiAuth::RequestDrivers::RestClientRequest.new(request) 61 | expect(driven_request.calculated_hash).to eq('47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=') 62 | end 63 | end 64 | 65 | describe 'http_method' do 66 | context 'when put request' do 67 | let(:request) do 68 | RestClient::Request.new( 69 | url: 'https://localhost/resource.xml?foo=bar&bar=foo', 70 | headers: request_headers, 71 | method: :put 72 | ) 73 | end 74 | 75 | it 'returns upcased put' do 76 | expect(driven_request.http_method).to eq('PUT') 77 | end 78 | end 79 | 80 | context 'when get request' do 81 | let(:request) do 82 | RestClient::Request.new( 83 | url: 'https://localhost/resource.xml?foo=bar&bar=foo', 84 | headers: request_headers, 85 | method: :get 86 | ) 87 | end 88 | 89 | it 'returns upcased get' do 90 | expect(driven_request.http_method).to eq('GET') 91 | end 92 | end 93 | end 94 | end 95 | 96 | describe 'setting headers correctly' do 97 | let(:request_headers) do 98 | { 99 | 'Content-Type' => 'text/plain' 100 | } 101 | end 102 | 103 | describe '#populate_content_hash' do 104 | context 'when getting' do 105 | let(:request) do 106 | RestClient::Request.new( 107 | url: 'https://localhost/resource.xml?foo=bar&bar=foo', 108 | headers: request_headers, 109 | method: :get 110 | ) 111 | end 112 | 113 | it "doesn't populate content hash" do 114 | driven_request.populate_content_hash 115 | expect(request.headers['X-Authorization-Content-SHA256']).to be_nil 116 | end 117 | end 118 | 119 | context 'when posting' do 120 | let(:request) do 121 | RestClient::Request.new( 122 | url: 'https://localhost/resource.xml?foo=bar&bar=foo', 123 | headers: request_headers, 124 | method: :post, 125 | payload: "hello\nworld" 126 | ) 127 | end 128 | 129 | it 'populates content hash' do 130 | driven_request.populate_content_hash 131 | expect(request.headers['X-Authorization-Content-SHA256']).to eq('JsYKYdAdtYNspw/v1EpqAWYgQTyO9fJZpsVhLU9507g=') 132 | end 133 | 134 | it 'refreshes the cached headers' do 135 | driven_request.populate_content_hash 136 | expect(driven_request.content_hash).to eq('JsYKYdAdtYNspw/v1EpqAWYgQTyO9fJZpsVhLU9507g=') 137 | end 138 | end 139 | 140 | context 'when putting' do 141 | let(:request) do 142 | RestClient::Request.new( 143 | url: 'https://localhost/resource.xml?foo=bar&bar=foo', 144 | headers: request_headers, 145 | method: :put, 146 | payload: "hello\nworld" 147 | ) 148 | end 149 | 150 | it 'populates content hash' do 151 | driven_request.populate_content_hash 152 | expect(request.headers['X-Authorization-Content-SHA256']).to eq('JsYKYdAdtYNspw/v1EpqAWYgQTyO9fJZpsVhLU9507g=') 153 | end 154 | 155 | it 'refreshes the cached headers' do 156 | driven_request.populate_content_hash 157 | expect(driven_request.content_hash).to eq('JsYKYdAdtYNspw/v1EpqAWYgQTyO9fJZpsVhLU9507g=') 158 | end 159 | end 160 | 161 | context 'when deleting' do 162 | let(:request) do 163 | RestClient::Request.new( 164 | url: 'https://localhost/resource.xml?foo=bar&bar=foo', 165 | headers: request_headers, 166 | method: :delete 167 | ) 168 | end 169 | 170 | it "doesn't populate content hash" do 171 | driven_request.populate_content_hash 172 | expect(request.headers['X-Authorization-Content-SHA256']).to be_nil 173 | end 174 | end 175 | end 176 | 177 | describe '#set_date' do 178 | before do 179 | allow(Time).to receive_message_chain(:now, :utc, :httpdate).and_return(timestamp) 180 | end 181 | 182 | it 'sets the date header of the request' do 183 | allow(Time).to receive_message_chain(:now, :utc, :httpdate).and_return(timestamp) 184 | driven_request.set_date 185 | expect(request.headers['DATE']).to eq(timestamp) 186 | end 187 | 188 | it 'refreshes the cached headers' do 189 | driven_request.set_date 190 | expect(driven_request.timestamp).to eq(timestamp) 191 | end 192 | end 193 | 194 | describe '#set_auth_header' do 195 | it 'sets the auth header' do 196 | driven_request.set_auth_header('APIAuth 1044:54321') 197 | expect(request.headers['Authorization']).to eq('APIAuth 1044:54321') 198 | end 199 | end 200 | end 201 | 202 | describe 'content_hash_mismatch?' do 203 | context 'when getting' do 204 | let(:request) do 205 | RestClient::Request.new( 206 | url: 'https://localhost/resource.xml?foo=bar&bar=foo', 207 | headers: request_headers, 208 | method: :get 209 | ) 210 | end 211 | 212 | it 'is false' do 213 | expect(driven_request.content_hash_mismatch?).to be false 214 | end 215 | end 216 | 217 | context 'when posting' do 218 | let(:request) do 219 | RestClient::Request.new( 220 | url: 'https://localhost/resource.xml?foo=bar&bar=foo', 221 | headers: request_headers, 222 | method: :post, 223 | payload: "hello\nworld" 224 | ) 225 | end 226 | 227 | context 'when calculated matches sent' do 228 | let(:request_headers) do 229 | { 230 | 'Authorization' => 'APIAuth 1044:12345', 231 | 'X-Authorization-Content-SHA256' => 'JsYKYdAdtYNspw/v1EpqAWYgQTyO9fJZpsVhLU9507g=', 232 | 'Content-Type' => 'text/plain', 233 | 'Date' => timestamp 234 | } 235 | end 236 | 237 | it 'is false' do 238 | expect(driven_request.content_hash_mismatch?).to be false 239 | end 240 | end 241 | 242 | context "when calculated doesn't match sent" do 243 | let(:request_headers) do 244 | { 245 | 'Authorization' => 'APIAuth 1044:12345', 246 | 'X-Authorization-Content-SHA256' => '3', 247 | 'Content-Type' => 'text/plain', 248 | 'Date' => timestamp 249 | } 250 | end 251 | 252 | it 'is true' do 253 | expect(driven_request.content_hash_mismatch?).to be true 254 | end 255 | end 256 | end 257 | 258 | context 'when putting' do 259 | let(:request) do 260 | RestClient::Request.new( 261 | url: 'https://localhost/resource.xml?foo=bar&bar=foo', 262 | headers: request_headers, 263 | method: :put, 264 | payload: "hello\nworld" 265 | ) 266 | end 267 | 268 | context 'when calculated matches sent' do 269 | let(:request_headers) do 270 | { 271 | 'Authorization' => 'APIAuth 1044:12345', 272 | 'X-Authorization-Content-SHA256' => 'JsYKYdAdtYNspw/v1EpqAWYgQTyO9fJZpsVhLU9507g=', 273 | 'Content-Type' => 'text/plain', 274 | 'Date' => timestamp 275 | } 276 | end 277 | 278 | it 'is false' do 279 | expect(driven_request.content_hash_mismatch?).to be false 280 | end 281 | end 282 | 283 | context "when calculated doesn't match sent" do 284 | let(:request_headers) do 285 | { 286 | 'Authorization' => 'APIAuth 1044:12345', 287 | 'X-Authorization-Content-SHA256' => '3', 288 | 'Content-Type' => 'text/plain', 289 | 'Date' => timestamp 290 | } 291 | end 292 | 293 | it 'is true' do 294 | expect(driven_request.content_hash_mismatch?).to be true 295 | end 296 | end 297 | end 298 | 299 | context 'when deleting' do 300 | let(:request) do 301 | RestClient::Request.new( 302 | url: 'https://localhost/resource.xml?foo=bar&bar=foo', 303 | headers: request_headers, 304 | method: :delete 305 | ) 306 | end 307 | 308 | it 'is false' do 309 | expect(driven_request.content_hash_mismatch?).to be false 310 | end 311 | end 312 | end 313 | 314 | describe 'authentics?' do 315 | context 'when getting' do 316 | let(:request) do 317 | RestClient::Request.new( 318 | url: 'https://localhost/resource.xml?foo=bar&bar=foo', 319 | method: :get 320 | ) 321 | end 322 | 323 | let(:signed_request) do 324 | ApiAuth.sign!(request, '1044', '123') 325 | end 326 | 327 | it 'validates that the signature in the request header matches the way we sign it' do 328 | expect(ApiAuth.authentic?(signed_request, '123')).to eq true 329 | end 330 | end 331 | 332 | context 'when posting' do 333 | let(:request) do 334 | RestClient::Request.new( 335 | url: 'https://localhost/resource.xml?foo=bar&bar=foo', 336 | method: :post, 337 | payload: "hello\nworld" 338 | ) 339 | end 340 | 341 | let(:signed_request) do 342 | ApiAuth.sign!(request, '1044', '123') 343 | end 344 | 345 | it 'validates that the signature in the request header matches the way we sign it' do 346 | expect(ApiAuth.authentic?(signed_request, '123')).to eq true 347 | end 348 | end 349 | 350 | context 'when putting' do 351 | let(:request) do 352 | RestClient::Request.new( 353 | url: 'https://localhost/resource.xml?foo=bar&bar=foo', 354 | method: :put, 355 | payload: "hello\nworld" 356 | ) 357 | end 358 | 359 | let(:signed_request) do 360 | ApiAuth.sign!(request, '1044', '123') 361 | end 362 | 363 | it 'validates that the signature in the request header matches the way we sign it' do 364 | expect(ApiAuth.authentic?(signed_request, '123')).to eq true 365 | end 366 | end 367 | 368 | context 'when deleting' do 369 | let(:request) do 370 | RestClient::Request.new( 371 | url: 'https://localhost/resource.xml?foo=bar&bar=foo', 372 | method: :delete 373 | ) 374 | end 375 | 376 | let(:signed_request) do 377 | ApiAuth.sign!(request, '1044', '123') 378 | end 379 | 380 | it 'validates that the signature in the request header matches the way we sign it' do 381 | expect(ApiAuth.authentic?(signed_request, '123')).to eq true 382 | end 383 | end 384 | end 385 | 386 | describe 'edge cases' do 387 | it "doesn't mess up symbol based headers" do 388 | headers = { 'X-Authorization-Content-SHA256' => 'e59ff97941044f85df5297e1c302d260', 389 | :content_type => 'text/plain', 390 | 'Date' => 'Mon, 23 Jan 1984 03:29:56 GMT' } 391 | request = RestClient::Request.new(url: 'https://localhost/resource.xml?foo=bar&bar=foo', 392 | headers: headers, 393 | method: :put) 394 | ApiAuth.sign!(request, 'some access id', 'some secret key') 395 | expect(request.processed_headers).to have_key('Content-Type') 396 | end 397 | end 398 | 399 | describe 'fetch_headers' do 400 | it 'returns request headers' do 401 | expect(driven_request.fetch_headers).to include('CONTENT-TYPE' => 'text/plain') 402 | end 403 | end 404 | end 405 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib')) 2 | $LOAD_PATH.unshift(File.dirname(__FILE__)) 3 | require 'rspec' 4 | 5 | require 'active_support' 6 | require 'active_support/test_case' 7 | require 'action_controller' 8 | require 'action_controller/test_case' 9 | require 'active_resource' 10 | require 'active_resource/http_mock' 11 | 12 | require 'api_auth' 13 | require 'amatch' 14 | require 'rest_client' 15 | require 'curb' 16 | require 'http' 17 | require 'httpi' 18 | require 'faraday' 19 | require 'grape' 20 | require 'net/http/post/multipart' 21 | 22 | # Requires supporting files with custom matchers and macros, etc, 23 | # in ./support/ and its subdirectories. 24 | Dir["#{File.dirname(__FILE__)}/support/**/*.rb"].sort.each { |f| require f } 25 | --------------------------------------------------------------------------------