├── .rspec ├── examples ├── mauth_key ├── Gemfile ├── README.md └── get_country_info.rb ├── .ruby-version ├── .release-please-manifest.json ├── lib ├── rack │ └── mauth.rb ├── mauth-client.rb └── mauth │ ├── version.rb │ ├── core_ext.rb │ ├── server_helper.rb │ ├── autoload.rb │ ├── private_key_helper.rb │ ├── middleware.rb │ ├── errors.rb │ ├── fake │ └── rack.rb │ ├── client │ ├── signer.rb │ ├── security_token_cacher.rb │ └── authenticator.rb │ ├── config_env.rb │ ├── proxy.rb │ ├── faraday.rb │ ├── rack.rb │ ├── client.rb │ └── request_and_response.rb ├── .yardopts ├── spec ├── fixtures │ ├── blank.jpeg │ └── mauth_signature_testing.json ├── spec_helper.rb ├── support │ ├── shared_contexts │ │ ├── fake_connection.rb │ │ ├── test_signable_request.rb │ │ └── client.rb │ └── shared_examples │ │ └── authenticator.rb ├── server_helper_spec.rb ├── default_config_spec.rb ├── private_key_helper_spec.rb ├── proxy_spec.rb ├── config_env_spec.rb ├── test_suite_parser.rb ├── protocol_test_suite_spec.rb ├── client │ ├── security_token_cacher_spec.rb │ ├── signer_spec.rb │ └── authenticator_spec.rb ├── client_spec.rb ├── signable_spec.rb └── middleware_spec.rb ├── .gitmodules ├── Appraisals ├── .gitignore ├── .github ├── PULL_REQUEST_TEMPLATE.md ├── workflows │ ├── release-please.yml │ ├── fossa.yml │ ├── publish.yml │ └── ci.yml └── dependabot.yml ├── release-please-config.json ├── Gemfile ├── gemfiles ├── faraday_1.x.gemfile └── faraday_2.x.gemfile ├── doc ├── implementations.md ├── mauth-client_CLI.md └── mauth-proxy.md ├── UPGRADE_GUIDE.md ├── LICENSE.txt ├── .rubocop.yml ├── CONTRIBUTING.md ├── exe ├── mauth-proxy └── mauth-client ├── mauth-client.gemspec ├── Rakefile ├── CHANGELOG.md └── README.md /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | -------------------------------------------------------------------------------- /examples/mauth_key: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 2.7.8 2 | -------------------------------------------------------------------------------- /.release-please-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | ".": "7.3.0" 3 | } 4 | -------------------------------------------------------------------------------- /lib/rack/mauth.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'mauth/rack' 4 | -------------------------------------------------------------------------------- /lib/mauth-client.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'mauth/client' 4 | -------------------------------------------------------------------------------- /.yardopts: -------------------------------------------------------------------------------- 1 | --main README.md 2 | --files doc/**/*.md 3 | lib/**/*.rb 4 | --output-dir ./yardoc 5 | -------------------------------------------------------------------------------- /lib/mauth/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module MAuth 4 | VERSION = '7.3.0' 5 | end 6 | -------------------------------------------------------------------------------- /spec/fixtures/blank.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mdsol/mauth-client-ruby/master/spec/fixtures/blank.jpeg -------------------------------------------------------------------------------- /examples/Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | gem 'mauth-client', path: '..' 6 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "spec/fixtures/mauth-protocol-test-suite"] 2 | path = spec/fixtures/mauth-protocol-test-suite 3 | url = https://github.com/mdsol/mauth-protocol-test-suite.git 4 | -------------------------------------------------------------------------------- /Appraisals: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | appraise 'faraday_1.x' do 4 | gem 'faraday', '~> 1.10' 5 | end 6 | 7 | appraise 'faraday_2.x' do 8 | gem 'faraday', '~> 2.0' 9 | end 10 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'timecop' 4 | require 'json' 5 | require 'rack/mock' 6 | require 'byebug' 7 | require 'webmock/rspec' 8 | 9 | require 'simplecov' 10 | SimpleCov.start 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .bundle 2 | .yardoc 3 | 4 | /config 5 | /coverage 6 | /yardoc 7 | /log 8 | 9 | # Appraisal related files 10 | /gemfiles/.bundle/ 11 | /gemfiles/*.gemfile.lock 12 | 13 | /Gemfile.lock 14 | /examples/Gemfile.lock 15 | .byebug_history 16 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | ## Checklist 5 | 6 | - [ ] rebased onto latest `master` 7 | - [ ] followed the [Conventional Commits](https://www.conventionalcommits.org) convention 8 | - [ ] appraisal files updated (`bundle exec appraisal update`) 9 | -------------------------------------------------------------------------------- /release-please-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "release-type": "ruby", 3 | "include-component-in-tag": false, 4 | "include-v-in-tag": true, 5 | "packages": { 6 | ".": { 7 | "package-name": "mauth-client", 8 | "version-file": "lib/mauth/version.rb" 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /lib/mauth/core_ext.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Hash 4 | # like stringify_keys, but does not attempt to stringify anything other than Symbols. 5 | # other keys are left alone. 6 | def stringify_symbol_keys 7 | inject({}) { |acc, (k, v)| acc.update((k.is_a?(Symbol) ? k.to_s : k) => v) } 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/mauth/server_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module MAuth 4 | module ServerHelper 5 | def app_uuid(request) 6 | request.env[MAuth::Client::RACK_ENV_APP_UUID_KEY] 7 | end 8 | 9 | def app_uuid_from_env(env) 10 | env[MAuth::Client::RACK_ENV_APP_UUID_KEY] 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /.github/workflows/release-please.yml: -------------------------------------------------------------------------------- 1 | name: release-please 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | permissions: 9 | contents: write 10 | pull-requests: write 11 | 12 | jobs: 13 | release-please: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: google-github-actions/release-please-action@v4 17 | -------------------------------------------------------------------------------- /lib/mauth/autoload.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module MAuth 4 | autoload :Client, 'mauth/client' 5 | autoload :Middleware, 'mauth/middleware' 6 | autoload :Faraday, 'mauth/faraday' 7 | autoload :Rack, 'mauth/rack' 8 | autoload :Request, 'mauth/request_and_response' 9 | autoload :Response, 'mauth/request_and_response' 10 | end 11 | -------------------------------------------------------------------------------- /.github/workflows/fossa.yml: -------------------------------------------------------------------------------- 1 | name: FOSSA License Check 2 | 3 | on: 4 | push: 5 | # branches: 6 | # - master 7 | pull_request: 8 | 9 | jobs: 10 | fossa-scan: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: mdsol/fossa_ci_scripts@main 15 | env: 16 | FOSSA_API_KEY: ${{ secrets.FOSSA_API_KEY }} 17 | FOSSA_FAIL_BUILD: false 18 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | # Specify your gem's dependencies in mauth-client.gemspec 6 | gemspec 7 | 8 | group :development do 9 | gem 'appraisal', '~> 2.4' 10 | gem 'benchmark-ips', '~> 2.7' 11 | gem 'bundler', '>= 1.17' 12 | gem 'byebug', '~> 11.1' 13 | gem 'rack-test', '~> 1.1' 14 | gem 'rake', '~> 12.0' 15 | gem 'rspec', '~> 3.8' 16 | gem 'rubocop', '~> 1.25' 17 | gem 'rubocop-mdsol', '~> 0.1' 18 | gem 'rubocop-performance', '~> 1.13' 19 | gem 'simplecov', '~> 0.16' 20 | gem 'timecop', '~> 0.9' 21 | gem 'webmock', '~> 3.0' 22 | end 23 | -------------------------------------------------------------------------------- /gemfiles/faraday_1.x.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "faraday", "~> 1.10" 6 | 7 | group :development do 8 | gem "appraisal", "~> 2.4" 9 | gem "benchmark-ips", "~> 2.7" 10 | gem "bundler", ">= 1.17" 11 | gem "byebug", "~> 11.1" 12 | gem "rack-test", "~> 1.1" 13 | gem "rake", "~> 12.0" 14 | gem "rspec", "~> 3.8" 15 | gem "rubocop", "~> 1.25" 16 | gem "rubocop-mdsol", "~> 0.1" 17 | gem "rubocop-performance", "~> 1.13" 18 | gem "simplecov", "~> 0.16" 19 | gem "timecop", "~> 0.9" 20 | gem "webmock", "~> 3.0" 21 | end 22 | 23 | gemspec path: "../" 24 | -------------------------------------------------------------------------------- /gemfiles/faraday_2.x.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "faraday", "~> 2.0" 6 | 7 | group :development do 8 | gem "appraisal", "~> 2.4" 9 | gem "benchmark-ips", "~> 2.7" 10 | gem "bundler", ">= 1.17" 11 | gem "byebug", "~> 11.1" 12 | gem "rack-test", "~> 1.1" 13 | gem "rake", "~> 12.0" 14 | gem "rspec", "~> 3.8" 15 | gem "rubocop", "~> 1.25" 16 | gem "rubocop-mdsol", "~> 0.1" 17 | gem "rubocop-performance", "~> 1.13" 18 | gem "simplecov", "~> 0.16" 19 | gem "timecop", "~> 0.9" 20 | gem "webmock", "~> 3.0" 21 | end 22 | 23 | gemspec path: "../" 24 | -------------------------------------------------------------------------------- /doc/implementations.md: -------------------------------------------------------------------------------- 1 | # MAuth client implementations 2 | 3 | - .Net: [mauth-client-dotnet](https://github.com/mdsol/mauth-client-dotnet) 4 | - Clojure: [clojure-mauth-client](https://github.com/mdsol/clojure-mauth-client) 5 | - Go: [go-mauth-client](https://github.com/mdsol/go-mauth-client) 6 | - Java: [mauth-jvm-clients](https://github.com/mdsol/mauth-jvm-clients) 7 | - Python: [mauth-client-python](https://github.com/mdsol/mauth-client-python) 8 | - R: [RMauthClient](https://github.com/mdsol/RMauthClient) 9 | - Ruby: [mauth-client-ruby](https://github.com/mdsol/mauth-client-ruby) 10 | - Rust: [mauth-client-rust](https://github.com/mdsol/mauth-client-rust) 11 | -------------------------------------------------------------------------------- /UPGRADE_GUIDE.md: -------------------------------------------------------------------------------- 1 | # Upgrade Guide 2 | 3 | ## Versions 4 | - [Upgrading to 7.0.0](#upgrading-to-700) 5 | 6 | ### Upgrading to 7.0.0 7 | 8 | Version 7.0.0 drops dice_bag. 9 | 10 | Please remove the following files and update the `.gitignore` file accordingly: 11 | - `config/initializers/mauth.rb.dice` (rename to `mauth.rb` and remove the top line `<%= warning.as_yaml_comment %>`) 12 | - `config/mauth_key` 13 | - `config/mauth_key.dice` 14 | - `config/mauth.yml` 15 | - `config/mauth.yml.dice` 16 | 17 | Prepend `MAUTH_` to the following environment variables: 18 | - `V2_ONLY_SIGN_REQUESTS` 19 | - `V2_ONLY_AUTHENTICATE` 20 | - `DISABLE_FALLBACK_TO_V1_ON_V2_FAILURE` 21 | - `V1_ONLY_SIGN_REQUESTS` 22 | -------------------------------------------------------------------------------- /spec/support/shared_contexts/fake_connection.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.shared_context 'with FakeConnection' do 4 | before do 5 | stub_const( 6 | 'FakeResponse', 7 | Class.new do 8 | attr_accessor :headers, :status, :body 9 | 10 | def initialize 11 | @headers = {} 12 | @status = 200 13 | end 14 | end 15 | ) 16 | 17 | stub_const( 18 | 'FakeConnection', 19 | Class.new do 20 | attr_accessor :headers 21 | 22 | def run_request(_request_method, _request_fullpath, _request_body, request_headers) 23 | @headers = request_headers 24 | FakeResponse.new 25 | end 26 | end 27 | ) 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | 8 | updates: 9 | - package-ecosystem: bundler 10 | directory: / 11 | insecure-external-code-execution: allow 12 | schedule: 13 | interval: weekly 14 | allow: 15 | - dependency-type: all 16 | groups: 17 | dependencies: 18 | patterns: 19 | - "*" 20 | 21 | - package-ecosystem: github-actions 22 | directory: / 23 | schedule: 24 | interval: weekly 25 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | release: 5 | types: 6 | - published 7 | workflow_dispatch: 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | build: 14 | name: Build + Publish 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | 20 | - name: Set up Ruby 21 | uses: ruby/setup-ruby@v1 22 | with: 23 | ruby-version: 3.4 24 | 25 | - name: Publish to RubyGems 26 | run: | 27 | mkdir -p $HOME/.gem 28 | touch $HOME/.gem/credentials 29 | chmod 0600 $HOME/.gem/credentials 30 | printf -- "---\n:rubygems_api_key: ${GEM_HOST_API_KEY}\n" > $HOME/.gem/credentials 31 | gem build *.gemspec 32 | gem push *.gem 33 | env: 34 | GEM_HOST_API_KEY: "${{ secrets.RUBYGEMS_AUTH_TOKEN }}" 35 | -------------------------------------------------------------------------------- /lib/mauth/private_key_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'openssl' 4 | 5 | module MAuth 6 | module PrivateKeyHelper 7 | HEADER = '-----BEGIN RSA PRIVATE KEY-----' 8 | FOOTER = '-----END RSA PRIVATE KEY-----' 9 | 10 | module_function 11 | 12 | def generate 13 | OpenSSL::PKey::RSA.generate(2048) 14 | end 15 | 16 | def load(key) 17 | OpenSSL::PKey::RSA.new(to_rsa_format(key)) 18 | rescue OpenSSL::PKey::RSAError 19 | raise 'The private key provided is invalid' 20 | end 21 | 22 | def to_rsa_format(key) 23 | return key if key.include?("\n") 24 | 25 | body = key.strip.delete_prefix(HEADER).delete_suffix(FOOTER).strip 26 | body = body.include?("\s") ? body.tr("\s", "\n") : body.scan(/.{1,64}/).join("\n") 27 | "#{HEADER}\n#{body}\n#{FOOTER}" 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/server_helper_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'mauth/client' 5 | require 'mauth/server_helper' 6 | 7 | describe MAuth::ServerHelper do 8 | subject { described_class } 9 | let(:uuid) { '724d1fab-79d6-4ddf-b15b-3c1fe6bd549f' } 10 | let(:req_env) { Rack::MockRequest.env_for('http://frogs.world', { MAuth::Client::RACK_ENV_APP_UUID_KEY => uuid }) } 11 | let(:req) { Rack::Request.new(req_env) } 12 | 13 | let(:dummy_klass) { Class.new { extend MAuth::ServerHelper } } 14 | 15 | describe 'app_uuid' do 16 | it 'returns the authenticated app uuid from a Rack::Request object' do 17 | expect(dummy_klass.app_uuid(req)).to eq(uuid) 18 | end 19 | end 20 | 21 | describe 'app_uuid_from_env' do 22 | it 'returns the authenticated app uuid from an env hash' do 23 | expect(dummy_klass.app_uuid_from_env(req.env)).to eq(uuid) 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /spec/support/shared_contexts/test_signable_request.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'mauth/request_and_response' 4 | 5 | RSpec.shared_context 'with TestSignableRequest' do 6 | before do 7 | stub_const( 8 | 'TestSignableRequest', 9 | Class.new(MAuth::Request) do 10 | include MAuth::Signed 11 | attr_accessor :headers 12 | 13 | def merge_headers(headers) 14 | self.class.new(@attributes_for_signing).tap { |r| r.headers = (@headers || {}).merge(headers) } 15 | end 16 | 17 | def x_mws_time 18 | headers['X-MWS-Time'] 19 | end 20 | 21 | def x_mws_authentication 22 | headers['X-MWS-Authentication'] 23 | end 24 | 25 | def mcc_authentication 26 | headers['MCC-Authentication'] 27 | end 28 | 29 | def mcc_time 30 | headers['MCC-Time'] 31 | end 32 | end 33 | ) 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/mauth/middleware.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'mauth/core_ext' 4 | module MAuth 5 | # base class for middleware, common to both Faraday and Rack 6 | class Middleware 7 | def initialize(app, config = {}) 8 | @app = app 9 | # stringify symbol keys 10 | @config = config.stringify_symbol_keys 11 | end 12 | 13 | # returns a MAuth::Client - if one was given as 'mauth_client' when initializing the 14 | # middleware, then that one; otherwise the configurationg given to initialize the 15 | # middleware is passed along to make a new MAuth::Client. 16 | # 17 | # this method may be overloaded to provide more flexibility in providing a MAuth::Client 18 | def mauth_client 19 | require 'mauth/client' 20 | # @mauth_client ivar only used here for caching; should not be used by other methods, in 21 | # order that overloading #mauth_client will work 22 | @mauth_client ||= @config['mauth_client'] || MAuth::Client.new(@config) 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/default_config_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'mauth/client' 5 | 6 | describe MAuth::Client do 7 | describe '.default_config' do 8 | let(:logger) { double } 9 | 10 | def with_rails(rails_stuff) 11 | require 'ostruct' 12 | begin 13 | Object.const_set(:Rails, Struct.new(*rails_stuff.keys).new(*rails_stuff.values)) 14 | yield 15 | ensure 16 | Object.send(:remove_const, :Rails) 17 | end 18 | end 19 | 20 | it 'guesses everything' do 21 | expect(MAuth::Client.default_config['app_uuid']).to eq('fb17460e-9868-11e1-8399-0090f5ccb4d3') 22 | end 23 | 24 | it 'has logger option specified' do 25 | expect(MAuth::Client.default_config(logger: logger)['logger']).to eq(logger) 26 | end 27 | 28 | it 'has Rails.logger specified' do 29 | logger = Logger.new(StringIO.new) 30 | with_rails(logger: logger) do 31 | expect(MAuth::Client.default_config['logger']).to eq(logger) 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | ## Configuration 4 | 5 | After obtaining valid credentials you need to set the `MAUTH_APP_UUID`, `MAUTH_PRIVATE_KEY_FILE` and `REFERENCES_HOST` environment variables. 6 | You also need to provide a mauth key and put it in the `mauth_key` file. 7 | 8 | This folder contains its own Gemfile file to manage dependencies so you need to run 9 | ``` 10 | bundle install 11 | ``` 12 | before trying any of the scripts. 13 | 14 | 15 | ## Fetching a given user's info 16 | 17 | Simply run the provided shell script by passing an search term, for instance: 18 | ``` 19 | MAUTH_APP_UUID= MAUTH_PRIVATE_KEY_FILE=./mauth_key REFERENCES_HOST=https://references-innovate.imedidata.net ./get_country_info.rb Albania 20 | ``` 21 | 22 | This should print the country's info, something along the lines of: 23 | ``` 24 | [ 25 | { 26 | "uuid": "9301ff5a-6703-11e1-b86c-0800200c9a66", 27 | "name": "Albania", 28 | "three_letter_code": "ALB", 29 | "two_letter_code": "AL", 30 | "version": "2021-06-30T12:00:00Z", 31 | "country_code": "ALB" 32 | } 33 | ] 34 | ``` 35 | -------------------------------------------------------------------------------- /spec/support/shared_contexts/client.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'support/shared_contexts/test_signable_request' 4 | 5 | shared_context 'client' do 6 | include_context 'with TestSignableRequest' 7 | 8 | let(:app_uuid) { 'signer' } 9 | let(:request) { TestSignableRequest.new(verb: 'PUT', request_url: '/', body: 'himom') } 10 | let(:v2_only_sign_requests) { false } 11 | let(:v1_only_sign_requests) { false } 12 | let(:v2_only_authenticate) { false } 13 | let(:disable_fallback_to_v1_on_v2_failure) { false } 14 | let(:v1_signed_req) { client.signed_v1(request) } 15 | let(:v2_signed_req) { client.signed_v2(request) } 16 | let(:signing_key) { OpenSSL::PKey::RSA.generate(2048) } 17 | let(:client) do 18 | MAuth::Client.new( 19 | private_key: signing_key, 20 | app_uuid: app_uuid, 21 | v2_only_sign_requests: v2_only_sign_requests, 22 | v2_only_authenticate: v2_only_authenticate, 23 | v1_only_sign_requests: v1_only_sign_requests, 24 | disable_fallback_to_v1_on_v2_failure: disable_fallback_to_v1_on_v2_failure 25 | ) 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016 Medidata Solutions Worldwide 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_gem: 2 | rubocop-mdsol: rubocop.yml 3 | 4 | require: 5 | - rubocop-performance 6 | 7 | AllCops: 8 | Exclude: 9 | - gemfiles/**/* 10 | 11 | Gemspec/RequireMFA: 12 | Enabled: false 13 | 14 | Layout/ArgumentAlignment: 15 | EnforcedStyle: with_fixed_indentation 16 | 17 | Layout/FirstHashElementIndentation: 18 | EnforcedStyle: consistent 19 | 20 | Layout/LineLength: 21 | Exclude: 22 | - spec/client/authenticator_spec.rb 23 | 24 | Lint/MissingSuper: 25 | Exclude: 26 | - exe/mauth-client 27 | - lib/mauth/faraday.rb 28 | - lib/mauth/rack.rb 29 | 30 | Metrics/AbcSize: 31 | Exclude: 32 | - lib/mauth/client.rb 33 | - lib/mauth/client/authenticator.rb 34 | - lib/mauth/proxy.rb 35 | 36 | Metrics/MethodLength: 37 | Exclude: 38 | - lib/mauth/client.rb 39 | 40 | Metrics/ModuleLength: 41 | Exclude: 42 | - lib/mauth/client/authenticator.rb 43 | 44 | Naming/FileName: 45 | Exclude: 46 | - lib/mauth-client.rb 47 | 48 | Style/FrozenStringLiteralComment: 49 | Enabled: true 50 | 51 | Style/GlobalVars: 52 | Exclude: 53 | - exe/mauth-client 54 | 55 | Style/StringLiterals: 56 | Enabled: true 57 | EnforcedStyle: single_quotes 58 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Cloning the Repo 4 | 5 | This repo contains the submodule `mauth-protocol-test-suite` so requires a flag when initially cloning in order to clone and init submodules. 6 | 7 | ``` 8 | git clone --recurse-submodules git@github.com:mdsol/mauth-client-ruby.git 9 | ``` 10 | 11 | If you have already cloned a version of this repo before the submodule was introduced in version 6.1.2 then run 12 | 13 | ``` 14 | cd spec/fixtures/mauth-protocol-test-suite 15 | git submodule update --init 16 | ``` 17 | 18 | to init the submodule. 19 | 20 | ## General Information 21 | 22 | * Check out the latest develop to make sure the feature hasn't been implemented or the bug hasn't been fixed yet 23 | * Check out the issue tracker to make sure someone already hasn't requested it and/or contributed it 24 | * Fork the project 25 | * Start a feature/bugfix branch 26 | * Commit and push until you are happy with your contribution 27 | * Make sure to add tests for it. This is important so I don't break it in a future version unintentionally. 28 | 29 | ## Running Tests 30 | 31 | To run tests, first run `bundle install`. 32 | 33 | Next, run the tests: 34 | 35 | ``` 36 | bundle exec rspec 37 | ``` 38 | 39 | ## Running Benchmark 40 | 41 | If you make changes which could affect performance, please run the benchmark before and after the change as a sanity check. 42 | 43 | ``` 44 | bundle exec rake benchmark 45 | ``` 46 | -------------------------------------------------------------------------------- /examples/get_country_info.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | abort "USAGE: ./#{__FILE__} " unless ARGV.size == 1 5 | 6 | require 'bundler/setup' 7 | Bundler.require(:default) 8 | 9 | # get country information 10 | def get_country_info(search_term) 11 | get_data_from_references "countries.json?search_term=#{search_term}" 12 | end 13 | 14 | # fetch data from References 15 | def get_data_from_references(resource_name) 16 | puts "fetching #{resource_name}..." 17 | mauth_config = MAuth::ConfigEnv.load 18 | references_host = ENV.fetch('REFERENCES_HOST', 'https://references-innovate.imedidata.com') 19 | begin 20 | connection = Faraday::Connection.new(url: references_host) do |builder| 21 | builder.use MAuth::Faraday::RequestSigner, mauth_config 22 | builder.adapter Faraday.default_adapter 23 | end 24 | 25 | # get the data 26 | response = connection.get "/v1/#{resource_name}" 27 | puts "HTTP #{response.status}" 28 | 29 | # return the user info 30 | if response.status == 200 31 | result = JSON.parse(response.body) 32 | puts JSON.pretty_generate(result) 33 | result 34 | else 35 | puts response.body 36 | nil 37 | end 38 | rescue JSON::ParserError => e 39 | puts "Error parsing data from references: #{e.inspect}" 40 | puts e.backtrace.join("\n") 41 | end 42 | end 43 | 44 | get_country_info(ARGV[0]) 45 | -------------------------------------------------------------------------------- /lib/mauth/errors.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module MAuth 4 | # mAuth client was unable to verify the authenticity of a signed object (this does NOT mean the 5 | # object is inauthentic). typically due to a failure communicating with the mAuth service, in 6 | # which case the error may include the attribute mauth_service_response - a response from 7 | # the mauth service (if it was contactable at all), which may contain more information about 8 | # the error. 9 | class UnableToAuthenticateError < StandardError 10 | # the response from the MAuth service encountered when attempting to retrieve authentication 11 | attr_accessor :mauth_service_response 12 | end 13 | 14 | # used to indicate that an object was expected to be validly signed but its signature does not 15 | # match its contents, and so is inauthentic. 16 | class InauthenticError < StandardError; end 17 | 18 | # Used when the incoming request does not contain any mAuth related information 19 | class MAuthNotPresent < StandardError; end 20 | 21 | # required information for signing was missing 22 | class UnableToSignError < StandardError; end 23 | 24 | # used when an object has the V1 headers but not the V2 headers and the 25 | # V2_ONLY_AUTHENTICATE variable is set to true. 26 | class MissingV2Error < StandardError; end 27 | 28 | class Client 29 | class ConfigurationError < StandardError; end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /spec/private_key_helper_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'mauth/client' 5 | 6 | describe MAuth::PrivateKeyHelper do 7 | let(:private_key) { OpenSSL::PKey::RSA.generate(2048).to_s } 8 | let(:private_key_newlines_replaced_with_spaces) { private_key.tr("\n", ' ') } 9 | let(:private_key_no_newlines) { private_key.delete("\n") } 10 | let(:private_key_invalid) { 'abc' } 11 | 12 | describe 'generate' do 13 | it 'returns a RSA object' do 14 | expect(described_class.generate).to be_a_kind_of(OpenSSL::PKey::RSA) 15 | end 16 | end 17 | 18 | describe 'load' do 19 | it 'loads a private key string and returns a RSA object' do 20 | expect(described_class.load(private_key).to_s).to eq(private_key) 21 | end 22 | 23 | it 'loads a private key string (newlines are replaced with spaces) and returns a RSA object' do 24 | expect(described_class.load(private_key_newlines_replaced_with_spaces).to_s).to eq(private_key) 25 | end 26 | 27 | it 'loads a private key string (newlines are removed) and returns a RSA object' do 28 | expect(described_class.load(private_key_no_newlines).to_s).to eq(private_key) 29 | end 30 | 31 | it 'raises an error if the private key string is invalid' do 32 | expect do 33 | described_class.load(private_key_invalid) 34 | end.to raise_error('The private key provided is invalid') 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /exe/mauth-proxy: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | $LOAD_PATH.unshift File.expand_path('../lib', File.dirname(__FILE__)) 5 | 6 | require 'mauth/proxy' 7 | require 'rack' 8 | 9 | headers = [] 10 | headers_index = ARGV.find_index('--header') 11 | while headers_index 12 | headers << ARGV[headers_index + 1] 13 | ARGV.delete_at(headers_index + 1) 14 | ARGV.delete_at(headers_index) 15 | headers_index = ARGV.find_index('--header') 16 | end 17 | 18 | authenticate_responses = !ARGV.delete('--no-authenticate') 19 | browser_proxy = !ARGV.delete('--browser_proxy').nil? 20 | 21 | target_uri = browser_proxy ? ARGV : ARGV.pop 22 | 23 | if !target_uri || target_uri.empty? 24 | abort("Usage: mauth-proxy [rack options] --browser_proxy [--no-authenticate] ...\n" \ 25 | 'or: mauth-proxy [rack options] ') 26 | end 27 | 28 | rack_server_options = Rack::Server::Options.new.parse!(ARGV) 29 | 30 | # for security, this rack server will only accept local connections, so override Host 31 | # to 127.0.0.1 (from the default of 0.0.0.0) 32 | # 33 | # this means that the '-o' / '--host' option to Rack::Server::Options is ignored. 34 | rack_server_options[:Host] = '127.0.0.1' 35 | 36 | rack_server_options[:app] = MAuth::Proxy.new(target_uri, authenticate_responses: authenticate_responses, 37 | browser_proxy: browser_proxy, headers: headers) 38 | 39 | mauth_proxy_server = Rack::Server.new(rack_server_options) 40 | mauth_proxy_server.start 41 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | # This workflow will download a prebuilt Ruby version, install dependencies and run tests with Rake 6 | # For more information see: https://github.com/marketplace/actions/setup-ruby-jruby-and-truffleruby 7 | 8 | name: CI 9 | 10 | on: 11 | push: 12 | branches: 13 | - master 14 | pull_request: 15 | 16 | permissions: 17 | contents: read 18 | 19 | jobs: 20 | test: 21 | runs-on: ubuntu-latest 22 | timeout-minutes: 10 23 | 24 | concurrency: 25 | # Cancel intermediate builds 26 | group: ${{ github.workflow }}-${{ github.ref }}-${{ matrix.ruby-version }}-${{ matrix.appraisal }} 27 | cancel-in-progress: true 28 | 29 | strategy: 30 | matrix: 31 | ruby-version: ['2.7', '3.0', '3.1', '3.2', '3.3', '3.4'] 32 | appraisal: ['faraday_1.x', 'faraday_2.x'] 33 | 34 | env: 35 | BUNDLE_JOBS: 4 36 | BUNDLE_GEMFILE: gemfiles/${{ matrix.appraisal }}.gemfile 37 | 38 | steps: 39 | - uses: actions/checkout@v4 40 | with: 41 | submodules: true 42 | 43 | - name: Set up Ruby 44 | uses: ruby/setup-ruby@v1 45 | with: 46 | ruby-version: ${{ matrix.ruby-version }} 47 | bundler-cache: true # runs 'bundle install' and caches installed gems automatically 48 | 49 | - name: Run tests 50 | run: | 51 | bundle exec rspec 52 | bundle exec rubocop 53 | bundle exec rake benchmark 54 | -------------------------------------------------------------------------------- /mauth-client.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | lib = File.expand_path('lib', __dir__) 4 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 5 | require 'mauth/version' 6 | 7 | Gem::Specification.new do |spec| 8 | spec.name = 'mauth-client' 9 | spec.version = MAuth::VERSION 10 | spec.authors = ['Matthew Szenher', 'Aaron Suggs', 'Geoffrey Ducharme', 'Ethan'] 11 | spec.email = ['mszenher@mdsol.com'] 12 | spec.summary = 'Sign and authenticate requests and responses with mAuth authentication.' 13 | spec.description = 'Client for signing and authentication of requests and responses with mAuth authentication. ' \ 14 | 'Includes middleware for Rack and Faraday for incoming and outgoing requests and responses.' 15 | spec.homepage = 'https://github.com/mdsol/mauth-client-ruby' 16 | spec.license = 'MIT' 17 | spec.required_ruby_version = '>= 2.7.0' 18 | 19 | spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 20 | spec.bindir = 'exe' 21 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 22 | spec.require_paths = ['lib'] 23 | 24 | spec.add_dependency 'addressable', '~> 2.0' 25 | spec.add_dependency 'base64', '~> 0.2' 26 | spec.add_dependency 'coderay', '~> 1.0' 27 | spec.add_dependency 'faraday', '>= 1.9', '< 3.0' 28 | spec.add_dependency 'faraday-http-cache', '>= 2.0', '< 3.0' 29 | spec.add_dependency 'faraday-net_http_persistent' 30 | spec.add_dependency 'faraday-retry' 31 | spec.add_dependency 'net-http-persistent', '>= 3.1' 32 | spec.add_dependency 'rack', '> 2.2.3' 33 | spec.add_dependency 'term-ansicolor', '~> 1.0' 34 | end 35 | -------------------------------------------------------------------------------- /lib/mauth/fake/rack.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'mauth/rack' 4 | 5 | module MAuth 6 | module Rack 7 | # This middleware bypasses actual authentication (it does not invoke mauth_client.authentic?). It 8 | # instead uses a class attr method (is_authenic?) to determine if the request should be deemed authentic or not. 9 | # Requests are authentic by default and RequestAuthenticationFaker.authentic = false must be called 10 | # BEFORE EACH REQUEST in order to make a request inauthentic. 11 | # 12 | # This is for testing environments where you do not wish to rely on a mauth service for making requests. 13 | # 14 | # Note that if your application does not use env['mauth.app_uuid'] or env['mauth.authentic'] then it 15 | # may be simpler to simply omit the request authentication middleware entirely in your test environment 16 | # (rather than switching to this fake one), as all this does is add those keys to the request env. 17 | class RequestAuthenticationFaker < MAuth::Rack::RequestAuthenticator 18 | class << self 19 | def is_authentic? # rubocop:disable Naming/PredicateName 20 | @is_authentic.nil? ? true : @is_authentic 21 | end 22 | 23 | def authentic=(is_auth = true) # rubocop:disable Style/OptionalBooleanParameter 24 | @is_authentic = is_auth 25 | end 26 | end 27 | 28 | def call(env) 29 | retval = if should_authenticate?(env) 30 | mauth_request = MAuth::Rack::Request.new(env) 31 | env['mauth.protocol_version'] = mauth_request.protocol_version 32 | 33 | if self.class.is_authentic? 34 | @app.call(env.merge!(MAuth::Client::RACK_ENV_APP_UUID_KEY => mauth_request.signature_app_uuid, 35 | 'mauth.authentic' => true)) 36 | else 37 | response_for_inauthentic_request(env) 38 | end 39 | else 40 | @app.call(env) 41 | end 42 | 43 | # ensure that the next request is marked authenic unless the consumer of this middleware explicitly deems 44 | # otherwise 45 | self.class.authentic = true 46 | 47 | retval 48 | end 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /spec/proxy_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'mauth/proxy' 5 | require 'faraday' 6 | require 'support/shared_contexts/fake_connection' 7 | 8 | describe MAuth::Proxy do 9 | include_context 'with FakeConnection' 10 | 11 | describe '#initialize' do 12 | let(:double) { FakeConnection.new } 13 | let(:url) { 'http://test-url-not-here.no' } 14 | let(:env) { Rack::MockRequest.env_for(url, {}) } 15 | 16 | before do 17 | allow(Faraday).to receive(:new).and_return(double) 18 | end 19 | 20 | it 'makes requests with custom header' do 21 | mp = MAuth::Proxy.new(url, headers: ['Content-type: text/jordi']) 22 | mp.call(env) 23 | expect(double.headers['Content-type']).to eq('text/jordi') 24 | end 25 | 26 | it 'makes requests with multiple custom header' do 27 | mp = MAuth::Proxy.new(url, headers: ['Content-type: text/jordi', 'Accepts : text/jordi']) 28 | mp.call(env) 29 | expect(double.headers['Content-type']).to eq('text/jordi') 30 | expect(double.headers['Accepts']).to eq('text/jordi') 31 | end 32 | 33 | it 'raises an error if the header format is wrong' do 34 | expect do 35 | MAuth::Proxy.new(url, headers: ['Content-type= text/jordi']) 36 | end.to raise_error('Headers must be in the format of [key]:[value]') 37 | end 38 | 39 | it 'supports multiple :' do 40 | mp = MAuth::Proxy.new(url, headers: ['Expiry-time: 3/6/1981 12:01.00']) 41 | mp.call(env) 42 | expect(double.headers['Expiry-time']).to eq('3/6/1981 12:01.00') 43 | end 44 | 45 | it 'forwards headers that begin with HTTP_ except for HTTP_HOST and removes the HTTP_ prefix' do 46 | mp = MAuth::Proxy.new(url) 47 | http_headers = { 'HTTP_FOO' => 'bar_value', 'HTTP_HOST' => 'my_host', 'HTTP_BIZ' => 'buzz_value' } 48 | mp.call(Rack::MockRequest.env_for(url, http_headers)) 49 | expect(double.headers['FOO']).to eq('bar_value') 50 | expect(double.headers['BIZ']).to eq('buzz_value') 51 | expect(double.headers.keys).to_not include('HTTP_HOST') 52 | expect(double.headers.keys).to_not include('HOST') 53 | expect(double.headers.keys).to_not include('HTTP_FOO') 54 | expect(double.headers.keys).to_not include('HTTP_BIZ') 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /doc/mauth-client_CLI.md: -------------------------------------------------------------------------------- 1 | # MAuth-Client CLI Tool 2 | 3 | MAuth-Client provides a Command Line Interface (CLI) tool to make MAuth-signed requests and verify MAuth-signed responses. 4 | 5 | ## Installation 6 | 7 | The MAuth-Client CLI is part of the MAuth Client gem, refer to [the README](../README.md#installation) for installation instructions. 8 | 9 | ## Configuration 10 | 11 | The CLI is configured with the [MAuth environment variables](../README.md#Configuration) - see the readme doc for instructions. 12 | 13 | ## Usage 14 | 15 | The mauth-client executable should be available with `bundle exec`, once it has been installed in your Gemfile. 16 | If you installed the gem manually, you may not need to run `bundle exec`. 17 | 18 | ``` 19 | $ bundle exec mauth-client --help 20 | Usage: mauth-client [options] [body] 21 | -v, --[no-]verbose Run verbosely - output is like curl -v (this is the default) 22 | -q Run quietly - only outputs the response body (same as --no-verbose) 23 | --[no-]authenticate Authenticate the response received 24 | --[no-]color Color the output (defaults to color if the output device is a TTY) 25 | -t, --content-type CONTENT-TYPE Sets the Content-Type header of the request 26 | -H, --header LINE accepts a json string of additional headers to included. IE 'cache-expirey: 10, other: value 27 | --no-ssl-verify Disables SSL verification - use cautiously! 28 | ``` 29 | 30 | Examples: 31 | 32 | ``` 33 | bundle exec mauth-client GET https://eureka-innovate.imedidata.com/v1/apis 34 | ``` 35 | 36 | ``` 37 | bundle exec mauth-client GET https://eureka-innovate.imedidata.com/v1/deployments 38 | ``` 39 | 40 | ``` 41 | bundle exec mauth-client POST https://eureka-innovate.imedidata.com/v1/deployments '{"baseURI": "https://cupcakes.imedidata.com", "stage": "production", "apis": [{"name": "cupcakes", "version": "v1.0.0"}]}' 42 | ``` 43 | 44 | ## Output 45 | 46 | MAuth-Client CLI's default output is designed to look like the output of `curl -v`. 47 | It includes all headers, body, and other components of the http request. 48 | This can be suppressed with the `-q` (quiet) option, in which case only the response body will be output. 49 | The normal output (not the quiet version) is colorized by default if connected to a tty device (e.g. a terminal). 50 | -------------------------------------------------------------------------------- /lib/mauth/client/signer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'openssl' 4 | require 'mauth/errors' 5 | 6 | # methods to sign requests and responses. part of MAuth::Client 7 | 8 | module MAuth 9 | class Client 10 | SIGNING_DIGEST = OpenSSL::Digest.new('SHA512') 11 | 12 | module Signer 13 | UNABLE_TO_SIGN_ERR = UnableToSignError.new('mAuth client cannot sign without a private key!') 14 | 15 | # takes an outgoing request or response object, and returns an object of the same class 16 | # whose headers are updated to include mauth's signature headers 17 | def signed(object, attributes = {}) 18 | object.merge_headers(signed_headers(object, attributes)) 19 | end 20 | 21 | # signs with v1 only. used when signing responses to v1 requests. 22 | def signed_v1(object, attributes = {}) 23 | object.merge_headers(signed_headers_v1(object, attributes)) 24 | end 25 | 26 | def signed_v2(object, attributes = {}) 27 | object.merge_headers(signed_headers_v2(object, attributes)) 28 | end 29 | 30 | # takes a signable object (outgoing request or response). returns a hash of headers to be 31 | # applied to the object which comprises its signature. 32 | def signed_headers(object, attributes = {}) 33 | if v2_only_sign_requests? 34 | signed_headers_v2(object, attributes) 35 | elsif v1_only_sign_requests? 36 | signed_headers_v1(object, attributes) 37 | else # by default sign with both the v1 and v2 protocol 38 | signed_headers_v1(object, attributes).merge(signed_headers_v2(object, attributes)) 39 | end 40 | end 41 | 42 | def signed_headers_v1(object, attributes = {}) 43 | attributes = { time: Time.now.to_i.to_s, app_uuid: client_app_uuid }.merge(attributes) 44 | string_to_sign = object.string_to_sign_v1(attributes) 45 | signature = signature_v1(string_to_sign) 46 | { 'X-MWS-Authentication' => "#{MWS_TOKEN} #{client_app_uuid}:#{signature}", 'X-MWS-Time' => attributes[:time] } 47 | end 48 | 49 | def signed_headers_v2(object, attributes = {}) 50 | attributes = { time: Time.now.to_i.to_s, app_uuid: client_app_uuid }.merge(attributes) 51 | string_to_sign = object.string_to_sign_v2(attributes) 52 | signature = signature_v2(string_to_sign) 53 | { 54 | 'MCC-Authentication' => "#{MWSV2_TOKEN} #{client_app_uuid}:#{signature}#{AUTH_HEADER_DELIMITER}", 55 | 'MCC-Time' => attributes[:time] 56 | } 57 | end 58 | 59 | def signature_v1(string_to_sign) 60 | assert_private_key(UNABLE_TO_SIGN_ERR) 61 | hashed_string_to_sign = OpenSSL::Digest::SHA512.hexdigest(string_to_sign) 62 | Base64.encode64(private_key.private_encrypt(hashed_string_to_sign)).delete("\n") 63 | end 64 | 65 | def signature_v2(string_to_sign) 66 | assert_private_key(UNABLE_TO_SIGN_ERR) 67 | Base64.encode64(private_key.sign(SIGNING_DIGEST, string_to_sign)).delete("\n") 68 | end 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /lib/mauth/config_env.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module MAuth 4 | class ConfigEnv 5 | GITHUB_URL = 'https://github.com/mdsol/mauth-client-ruby' 6 | 7 | ENV_STUFF = { 8 | 'MAUTH_URL' => nil, 9 | 'MAUTH_API_VERSION' => 'v1', 10 | 'MAUTH_APP_UUID' => nil, 11 | 'MAUTH_PRIVATE_KEY' => nil, 12 | 'MAUTH_PRIVATE_KEY_FILE' => 'config/mauth_key', 13 | 'MAUTH_V2_ONLY_AUTHENTICATE' => false, 14 | 'MAUTH_V2_ONLY_SIGN_REQUESTS' => false, 15 | 'MAUTH_DISABLE_FALLBACK_TO_V1_ON_V2_FAILURE' => false, 16 | 'MAUTH_V1_ONLY_SIGN_REQUESTS' => true, 17 | 'MAUTH_USE_RAILS_CACHE' => false 18 | }.freeze 19 | 20 | class << self 21 | def load 22 | validate! if production? 23 | 24 | { 25 | 'mauth_baseurl' => env[:mauth_url] || 'http://localhost:7000', 26 | 'mauth_api_version' => env[:mauth_api_version], 27 | 'app_uuid' => env[:mauth_app_uuid] || 'fb17460e-9868-11e1-8399-0090f5ccb4d3', 28 | 'private_key' => private_key || PrivateKeyHelper.generate.to_s, 29 | 'v2_only_authenticate' => env[:mauth_v2_only_authenticate], 30 | 'v2_only_sign_requests' => env[:mauth_v2_only_sign_requests], 31 | 'disable_fallback_to_v1_on_v2_failure' => env[:mauth_disable_fallback_to_v1_on_v2_failure], 32 | 'v1_only_sign_requests' => env[:mauth_v1_only_sign_requests], 33 | 'use_rails_cache' => env[:mauth_use_rails_cache] 34 | } 35 | end 36 | 37 | private 38 | 39 | def validate! 40 | errors = [] 41 | errors << 'The MAUTH_URL environment variable must be set' if env[:mauth_url].nil? 42 | errors << 'The MAUTH_APP_UUID environment variable must be set' if env[:mauth_app_uuid].nil? 43 | errors << 'The MAUTH_PRIVATE_KEY environment variable must be set' if env[:mauth_private_key].nil? 44 | return if errors.empty? 45 | 46 | errors.map! { |err| "#{err} => See #{GITHUB_URL}" } 47 | errors.unshift('Invalid MAuth Client configuration:') 48 | raise errors.join("\n") 49 | end 50 | 51 | def env 52 | @env ||= ENV_STUFF.each_with_object({}) do |(key, default), hsh| 53 | env_key = key.downcase.to_sym 54 | hsh[env_key] = ENV.fetch(key, default) 55 | 56 | case default 57 | when TrueClass, FalseClass 58 | hsh[env_key] = hsh[env_key].to_s.casecmp('true').zero? 59 | end 60 | end 61 | end 62 | 63 | def production? 64 | environment.to_s.casecmp('production').zero? 65 | end 66 | 67 | def environment 68 | return Rails.environment if Object.const_defined?(:Rails) && ::Rails.respond_to?(:environment) 69 | 70 | ENV.fetch('RAILS_ENV') { ENV.fetch('RACK_ENV', 'development') } 71 | end 72 | 73 | def private_key 74 | return env[:mauth_private_key] if env[:mauth_private_key] 75 | return nil unless env[:mauth_private_key_file] && File.readable?(env[:mauth_private_key_file]) 76 | 77 | File.read(env[:mauth_private_key_file]) 78 | end 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /lib/mauth/client/security_token_cacher.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'faraday-http-cache' 4 | require 'faraday/retry' 5 | if Gem::Version.new(Faraday::VERSION) >= Gem::Version.new('2.0') 6 | require 'faraday/net_http_persistent' 7 | else 8 | require 'net/http/persistent' 9 | end 10 | require 'mauth/faraday' 11 | 12 | module MAuth 13 | class Client 14 | module Authenticator 15 | class SecurityTokenCacher 16 | attr_reader :mauth_client 17 | 18 | def initialize(mauth_client) 19 | @mauth_client = mauth_client 20 | # TODO: should this be UnableToSignError? 21 | mauth_client.assert_private_key( 22 | UnableToAuthenticateError.new('Cannot fetch public keys from mAuth service without a private key!') 23 | ) 24 | end 25 | 26 | def get(app_uuid) 27 | # url-encode the app_uuid to prevent trickery like escaping upward with ../../ in a malicious 28 | # app_uuid - probably not exploitable, but this is the right way to do it anyway. 29 | url_encoded_app_uuid = CGI.escape(app_uuid) 30 | path = "/mauth/#{mauth_client.mauth_api_version}/security_tokens/#{url_encoded_app_uuid}.json" 31 | response = signed_mauth_connection.get(path) 32 | 33 | case response.status 34 | when 200 35 | security_token_from(response.body) 36 | when 404 37 | # signing with a key mAuth doesn't know about is considered inauthentic 38 | raise InauthenticError, "mAuth service responded with 404 looking up public key for #{app_uuid}" 39 | else 40 | mauth_client.send(:mauth_service_response_error, response) 41 | end 42 | rescue ::Faraday::ConnectionFailed, ::Faraday::TimeoutError => e 43 | msg = "mAuth service did not respond; received #{e.class}: #{e.message}" 44 | mauth_client.logger.error("Unable to authenticate with MAuth. Exception #{msg}") 45 | raise UnableToAuthenticateError, msg 46 | end 47 | 48 | private 49 | 50 | def security_token_from(response_body) 51 | JSON.parse response_body 52 | rescue JSON::ParserError => e 53 | msg = "mAuth service responded with unparseable json: #{response_body}\n#{e.class}: #{e.message}" 54 | mauth_client.logger.error("Unable to authenticate with MAuth. Exception #{msg}") 55 | raise UnableToAuthenticateError, msg 56 | end 57 | 58 | def signed_mauth_connection 59 | @signed_mauth_connection ||= begin 60 | mauth_client.faraday_options[:ssl] = { ca_path: mauth_client.ssl_certs_path } if mauth_client.ssl_certs_path 61 | 62 | ::Faraday.new(mauth_client.mauth_baseurl, mauth_client.faraday_options) do |builder| 63 | builder.use MAuth::Faraday::MAuthClientUserAgent 64 | builder.use MAuth::Faraday::RequestSigner, 'mauth_client' => mauth_client 65 | builder.use :http_cache, store: mauth_client.cache_store, logger: mauth_client.logger, shared_cache: false 66 | builder.request :retry, max: 2 67 | builder.adapter :net_http_persistent 68 | end 69 | end 70 | end 71 | end 72 | end 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /spec/config_env_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'mauth/client' 5 | 6 | describe MAuth::ConfigEnv do 7 | describe '.load' do 8 | let(:config) { described_class.load } 9 | 10 | before do 11 | allow(ENV).to receive(:fetch).and_call_original 12 | MAuth::ConfigEnv.instance_variable_set(:@env, nil) 13 | end 14 | 15 | context 'configured by env vars' do 16 | before do 17 | allow(ENV).to receive(:fetch).with('MAUTH_URL', anything).and_return('https://mauth.com') 18 | allow(ENV).to receive(:fetch).with('MAUTH_API_VERSION', anything).and_return('v123') 19 | allow(ENV) 20 | .to receive(:fetch).with('MAUTH_APP_UUID', anything).and_return('9d10623d-7ee6-4088-91cf-fe2c660a98bc') 21 | allow(ENV).to receive(:fetch).with('MAUTH_PRIVATE_KEY', anything).and_return('configured key') 22 | allow(ENV).to receive(:fetch).with('MAUTH_V2_ONLY_AUTHENTICATE', anything).and_return('true') 23 | allow(ENV).to receive(:fetch).with('MAUTH_V2_ONLY_SIGN_REQUESTS', anything).and_return('true') 24 | allow(ENV).to receive(:fetch).with('MAUTH_DISABLE_FALLBACK_TO_V1_ON_V2_FAILURE', anything).and_return('true') 25 | allow(ENV).to receive(:fetch).with('MAUTH_V1_ONLY_SIGN_REQUESTS', anything).and_return('false') 26 | allow(ENV).to receive(:fetch).with('MAUTH_USE_RAILS_CACHE', anything).and_return('true') 27 | end 28 | 29 | it 'returns the processed config' do 30 | expect(config['mauth_baseurl']).to eq('https://mauth.com') 31 | expect(config['mauth_api_version']).to eq('v123') 32 | expect(config['app_uuid']).to eq('9d10623d-7ee6-4088-91cf-fe2c660a98bc') 33 | expect(config['private_key']).to eq('configured key') 34 | expect(config['v2_only_authenticate']).to be true 35 | expect(config['v2_only_sign_requests']).to be true 36 | expect(config['disable_fallback_to_v1_on_v2_failure']).to be true 37 | expect(config['v1_only_sign_requests']).to be false 38 | expect(config['use_rails_cache']).to be true 39 | end 40 | end 41 | 42 | context 'configured by defaults' do 43 | before { allow(OpenSSL::PKey::RSA).to receive(:generate).with(2048).and_return('generated key') } 44 | 45 | it 'returns the processed config' do 46 | expect(config['mauth_baseurl']).to eq('http://localhost:7000') 47 | expect(config['mauth_api_version']).to eq('v1') 48 | expect(config['app_uuid']).to eq('fb17460e-9868-11e1-8399-0090f5ccb4d3') 49 | expect(config['private_key']).to eq('generated key') 50 | expect(config['v2_only_authenticate']).to be false 51 | expect(config['v2_only_sign_requests']).to be false 52 | expect(config['disable_fallback_to_v1_on_v2_failure']).to be false 53 | expect(config['v1_only_sign_requests']).to be true 54 | expect(config['use_rails_cache']).to be false 55 | end 56 | end 57 | 58 | context 'running in production' do 59 | before { allow(ENV).to receive(:fetch).with('RAILS_ENV').and_return('production') } 60 | 61 | %w[MAUTH_URL MAUTH_APP_UUID MAUTH_PRIVATE_KEY].each do |env_var| 62 | it "requires the presence of the #{env_var} variable" do 63 | expect { config }.to raise_error(/#{env_var} environment variable must be set/) 64 | end 65 | end 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /spec/fixtures/mauth_signature_testing.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": "Cross platform testing for mAuth signatures", 3 | "private_key": "-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEAs3zgudWufUW/UFoQtxfBePRDqc63RdpvuBILKHNFAYpxxuSR\nMzQUKSrsvXpWf983/kTtvXBrj4Hm6qWC9fqGKh/qDTnjamXUzxg6utFbcrOrc4hs\nfvfRjEaymclTPLXRa9+iD3vTzDBqS85eDFDm5hTpPwc3C/7+iw0cfWqwP7jJsn5O\nvul50UOuR+czeBWuQXZPh21nUpEI7ZW8U3W2lmO2DrSez2FVtW3Oiek/AQz+uOtl\nNSPoJcH06O2dlh7AAF8c3eFTaYH4gBTFzcBUp3BneZ2cr0e5m5eVRcCOFqDGQF3Y\nJQT2I1EpMecRWo7UW48mF0wzVj/mK1rQUAebgQIDAQABAoIBACAXpf7UTByuCeUO\nFYsHPlqoIikMgwyEYBFjeIdFBQOfg3Ryjdu/5hLuT+IZK7o1aUeXf4KtxS2lpmoy\nKdZdcvu5NRokTZtKleBpjqa0pEtAANnpfKy/FsKkKW8B5lYmlElbdRibpWUPCxJ+\n1aYSGRbuij3wxlDoyQ6Hy55JIzZhQVaKxFr0YQS/v99EArjlhlMJDNqaFEMQ0WUO\n4h7a2ZwAcJ5aDBpCNqFnWg0Vm4u3GM7l50jC0poQGw7UamDL6DWS5UfontSNfBOe\naQO4998dq2FYZGeJiKbZPLzieb1Dk8CcXXgaXO5Lwyy2Rrux+SaSvMOLNki2e593\nlobKOgECgYEA2GQEymGpVY4cqKFHpPIlU/+PhXG9DcZpNzo1oJOxJ5vruoH3UnwV\nmjrP0/NZsjG+loADAhvdlNzBv5pfzmrdUtN/6+HY7zveWfMpdwUSYUaUq+kXDbFH\nNSHCjz+vS6GuOBZ0LvniMR6sAuV25BFP3oCXUxB0+8/z+iyiP4khqNECgYEA1Fea\ntFiD/4kAYivkEX8S1/628o1uqN6rPTB2t0KFaUvH+Mefqf9akZXWqUPbQc2LZEo9\nD6Ez5o51PRBYkHE/C4gfk6ujkwkl8XTgi5owpY73B75ook5mO1MfRIvfvcP1z2Ta\nR2p+uMUDJVO0C4L+RouUWUpkLP8BPg1rUM0sc7ECgYEAkVG6Fd+4RIiHnoeRAajM\ngLijvc5AVDvm9PvWf9wvoJYJnNsjKPXD3Cua3pASsKTPhWq6mnP0PsByLSaTKKCD\nudfnlJW7hg4CqQ2vzwpM6Z7owPpsTPm9BGWDr4fpRTVzNp99rv6JdMtQYTGQwmEN\n7jMVbOckaOeixWOsIlcJj8ECgYEAmYJ3ylePneZai551dBys79AqTLHoxVas70Ch\nIp2Ju3TYrccLa6e6vzNXC+mNkkXZtvhgqnL9BXoJ0cqGbG4iiOCxC13zlHHxp1y6\nlNI0xwvTFRsXo/cPu2W9Xh3M8/C+PWAI2cZotIVhX9PifswFrdRsvBymzUzRhh3H\nbpPVxhECgYAFRJ3qF9kj3TIqupZRknwvEVYMMOK4L8v8zaYjM9WQobcPkROmu7nn\nBzqqXY1SzEH4xRed4OqtTPtglA4jRC4Kr0V0H7terM9u3+p1OIWl7/5X79DYfqyW\nugSNZFZUII03sibgNFxMnj+5s6Bj3PvKjmUnd+Hrt4upKaHH0aRBtA==\n-----END RSA PRIVATE KEY-----", 4 | "attributes_for_signing": { 5 | "app_uuid": "5ff4257e-9c16-11e0-b048-0026bbfffe5e", 6 | "time": 1309891855, 7 | "verb": "PUT", 8 | "request_url": "/v1/pictures", 9 | "body": "", 10 | "query_string": "key=-_.~ !@#$%^*()+{}|:\"'`<>?&∞=v&キ=v&0=v&a=v&a=b&a=c&a=a&k=&k=v" 11 | }, 12 | "signatures": { 13 | "v1_binary": "hDKYDRnzPFL2gzsru4zn7c7E7KpEvexeF4F5IR+puDxYXrMmuT2/fETZty5NkGGTZQ1nI6BTYGQGsU/73TkEAm7SvbJZcB2duLSCn8H5D0S1cafory1gnL1TpMPBlY8J/lq/Mht2E17eYw+P87FcpvDShINzy8GxWHqfquBqO8ml4XtirVEtAlI0xlkAsKkVq4nj7rKZUMS85mzogjUAJn3WgpGCNXVU+EK+qElW5QXk3I9uozByZhwBcYt5Cnlg15o99+53wKzMMmdvFmVjA1DeUaSO7LMIuw4ZNLVdDcHJx7ZSpAKZ/EA34u1fYNECFcw5CSKOjdlU7JFr4o8Phw==", 14 | "v2_binary": "XggeV7lmWva1w/40TzCs/BVuLTqGDSM63jPu7tqAbswAMdxTAPs80JuefxQM1YwW1fT87bYFfwSPMPR2vK3rJIApXfynMf+fjIG6m6/0kNXjGEKYC5YSyGZmpNmL+sges4qU8g2Hd61EDQO/qRlDZkSEc/1+OvBpKqldQpjrd3DMp3n2uuPVyZKWH6ZMI07fzNlDdOpYjanhvKp8fhEBoDIeoYXuGyM1oB5ZveA1MahCDwoF4dA8ULeiN8mlE+f3IILnEJxBsGgypUrmyAch5JD1KunY4oEKQBzUfiHab1WPmjuk3VJiTOyET+HzJvQtvBJ/MsH95g3jpnF/tCG6fQ==", 15 | "v1_empty": "UxcRuPRLzjO70NUDG/v71vfs8t/8xyaKN7LTgt6IiV+ul4GRpp3b9EzmF8/b7OTlX3Bsxl7o+E1wfuf4AuqQKE5IqZuhNqZ2t2TPIFdeV4VeF4Eh+gWs6de0KERnEWMTH7OjJsSEQ1gdA7tB3wQhhnf7CpJgMc3P1dSONVgq9qIchspw6L4dadN5bzxH99hN1E/0iPd+qGIeczuhtPMuiNaZRjhFjr2ZsIqn0pYqF+u2czKXd76sZGiBYuUpp/5dQvXBK9v2JlXUmiCoa2LcPj55HR0YEqcPE0mV0k9hyJMwJZeeTKBS5g3QDxoPpB61/+sLuyNp2P/cWrvU03P9dQ==", 16 | "v2_empty": "ciYySw0xRe5lIm7I4JbmM+hIJGghNyihvRAto6GkeqpVFiSkku8te4GXMGBdckqAxe06XcBAP5MKgxeXitXpsL8JuVjo/j+1HPAKHktdIHiFBCNvPBRx2EE3doG/9aQqtaL1SKhjkiG8MHkiS4WKZ+k3QpMUYTbi4iBLPCc+8jcp7vTuc766KQHH9EwwksHovzNqDKyH+P4dX1BepMAwSIFZWljL7/NDWfSNd5awrKs5v9JcYXic5uTWG1JFWKujbva4zTa18rC/O7aGHsSQ2JpHjMqPZaGvkCgGMaXcgFJ2iJ495NyPubZAG8U9RYaJxngYCRG3cfE0/YJnDbcJKg==" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /lib/mauth/proxy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'mauth/client' 4 | require 'faraday' 5 | require 'rack' 6 | 7 | module MAuth 8 | # MAuth::Proxy is a simple Rack application to take incoming requests, sign them with MAuth, and 9 | # proxy them to a target URI. the responses from the target may be authenticated, with MAuth 10 | # (and are by default). 11 | class Proxy 12 | EXCLUDED_RESPONSE_HEADERS = %w[Content-Length Transfer-Encoding].map(&:downcase) 13 | 14 | # target_uri is the base relative to which requests are made. 15 | # 16 | # options: 17 | # - :authenticate_responses - boolean, default true. whether responses will be authenticated. 18 | # if this is true and an inauthentic response is encountered, then MAuth::InauthenticError 19 | # will be raised. 20 | # - :mauth_config - configuration passed to MAuth::Client.new (see its doc). default is 21 | # MAuth::Client.default_config 22 | def initialize(target_uri, options = {}) 23 | @target_uris = target_uri 24 | @browser_proxy = options.delete(:browser_proxy) 25 | @options = options 26 | options = { authenticate_responses: true }.merge(options) 27 | options[:mauth_config] ||= MAuth::Client.default_config 28 | if @browser_proxy # Browser proxy mode 29 | @signer_connection = ::Faraday.new do |builder| 30 | builder.use MAuth::Faraday::RequestSigner, options[:mauth_config] 31 | builder.use MAuth::Faraday::ResponseAuthenticator, options[:mauth_config] if options[:authenticate_responses] 32 | builder.adapter ::Faraday.default_adapter 33 | end 34 | @unsigned_connection = ::Faraday.new do |builder| 35 | builder.adapter ::Faraday.default_adapter 36 | end 37 | else # hard-wired mode 38 | @connection = ::Faraday.new(target_uri) do |builder| 39 | builder.use MAuth::Faraday::RequestSigner, options[:mauth_config] 40 | builder.use MAuth::Faraday::ResponseAuthenticator, options[:mauth_config] if options[:authenticate_responses] 41 | builder.adapter ::Faraday.default_adapter 42 | end 43 | end 44 | @persistent_headers = {} 45 | options[:headers]&.each do |cur| 46 | raise 'Headers must be in the format of [key]:[value]' unless cur.include?(':') 47 | 48 | key, _throw_away, value = cur.partition(':') 49 | @persistent_headers[key.strip] = value.strip 50 | end 51 | end 52 | 53 | def call(request_env) 54 | request = ::Rack::Request.new(request_env) 55 | request_method = request_env['REQUEST_METHOD'].downcase.to_sym 56 | request_env['rack.input'].rewind 57 | request_body = request_env['rack.input'].read 58 | request_env['rack.input'].rewind 59 | request_headers = {} 60 | request_env.each do |k, v| 61 | if k.start_with?('HTTP_') && k != 'HTTP_HOST' 62 | name = k.delete_prefix('HTTP_') 63 | request_headers[name] = v 64 | end 65 | end 66 | request_headers.merge!(@persistent_headers) 67 | if @browser_proxy 68 | target_uri = request_env['REQUEST_URI'] 69 | connection = @target_uris.any? { |u| target_uri.start_with? u } ? @signer_connection : @unsigned_connection 70 | response = connection.run_request(request_method, target_uri, request_body, request_headers) 71 | else 72 | response = @connection.run_request(request_method, request.fullpath, request_body, request_headers) 73 | end 74 | response_headers = response.headers.reject do |name, _value| 75 | EXCLUDED_RESPONSE_HEADERS.include?(name.downcase) 76 | end 77 | [response.status, response_headers, [response.body || '']] 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /lib/mauth/faraday.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'mauth/middleware' 4 | require 'mauth/request_and_response' 5 | 6 | Faraday::Request.register_middleware(mauth_request_signer: proc { MAuth::Faraday::RequestSigner }) 7 | Faraday::Response.register_middleware(mauth_response_authenticator: proc { MAuth::Faraday::ResponseAuthenticator }) 8 | 9 | module MAuth 10 | module Faraday 11 | # faraday middleware to sign outgoing requests 12 | class RequestSigner < MAuth::Middleware 13 | def call(request_env) 14 | signed_request_env = mauth_client.signed(MAuth::Faraday::Request.new(request_env)).request_env 15 | @app.call(signed_request_env) 16 | end 17 | end 18 | 19 | # faraday middleware to authenticate incoming responses 20 | class ResponseAuthenticator < MAuth::Middleware 21 | def call(request_env) 22 | @app.call(request_env).on_complete do |response_env| 23 | mauth_response = MAuth::Faraday::Response.new(response_env) 24 | mauth_client.authenticate!(mauth_response) # raises MAuth::InauthenticError when inauthentic 25 | response_env[MAuth::Client::RACK_ENV_APP_UUID_KEY] = mauth_response.signature_app_uuid 26 | response_env['mauth.authentic'] = true 27 | response_env 28 | end 29 | end 30 | end 31 | 32 | # representation of a request (outgoing) composed from a Faraday request env which can be 33 | # passed to a Mauth::Client for signing 34 | class Request < MAuth::Request 35 | attr_reader :request_env 36 | 37 | def initialize(request_env) 38 | @request_env = request_env 39 | end 40 | 41 | def attributes_for_signing 42 | @attributes_for_signing ||= begin 43 | request_url = @request_env[:url].path.empty? ? '/' : @request_env[:url].path 44 | { 45 | verb: @request_env[:method].to_s.upcase, 46 | request_url: request_url, 47 | body: @request_env[:body], 48 | query_string: @request_env[:url].query 49 | } 50 | end 51 | end 52 | 53 | # takes a Hash of headers; returns an instance of this class whose 54 | # headers have been merged with the argument headers 55 | def merge_headers(headers) 56 | self.class.new(@request_env.merge(request_headers: @request_env[:request_headers].merge(headers))) 57 | end 58 | end 59 | 60 | # representation of a Response (incoming) composed from a Faraday response env which can be 61 | # passed to a Mauth::Client for authentication 62 | class Response < MAuth::Response 63 | include Signed 64 | attr_reader :response_env 65 | 66 | def initialize(response_env) 67 | @response_env = response_env 68 | end 69 | 70 | def attributes_for_signing 71 | @attributes_for_signing ||= { status_code: response_env[:status], body: response_env[:body] } 72 | end 73 | 74 | def x_mws_time 75 | @response_env[:response_headers]['x-mws-time'] 76 | end 77 | 78 | def x_mws_authentication 79 | @response_env[:response_headers]['x-mws-authentication'] 80 | end 81 | 82 | def mcc_time 83 | @response_env[:response_headers]['mcc-time'] 84 | end 85 | 86 | def mcc_authentication 87 | @response_env[:response_headers]['mcc-authentication'] 88 | end 89 | end 90 | 91 | # add MAuth-Client's user-agent to a request 92 | class MAuthClientUserAgent 93 | def initialize(app, agent_base = 'Mauth-Client') 94 | @app = app 95 | @agent_base = agent_base 96 | end 97 | 98 | def call(request_env) 99 | agent = "#{@agent_base} (MAuth-Client: #{MAuth::VERSION}; Ruby: #{RUBY_VERSION}; platform: #{RUBY_PLATFORM})" 100 | request_env[:request_headers]['User-Agent'] ||= agent 101 | @app.call(request_env) 102 | end 103 | end 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /doc/mauth-proxy.md: -------------------------------------------------------------------------------- 1 | # mauth-proxy executable 2 | 3 | ## Overview 4 | 5 | mauth-proxy is a command-line tool to forward requests to a service, signing each one with a MAuth signature and verifying responses from the service. 6 | 7 | mauth-proxy wraps a Rack server, which listens on localhost (external connections are not allowed, for security). 8 | mauth-proxy takes each request, signs it with a specified MAuth configuration, and makes a request to the given service. 9 | The response from the service is authenticated with MAuth, and is returned as the response to the original request. 10 | 11 | The intent is to allow users to point any HTTP or REST client they care to use at a service which authenticates with MAuth, without the client needing to know how to generate MAuth signatures or authenticate MAuth-signed responses. 12 | 13 | The proxy has two modes: single-target and browser proxy mode. In browser proxy mode, it can be configured as a HTTP proxy in a browser and will direct the requests to any URL in the request while signing requests to URLs that are listed in the command line. 14 | In single-target mode, all requests will be directed to the server specified in the command line. 15 | 16 | ## Usage 17 | 18 | Single target mode: 19 | ``` 20 | $ bundle exec mauth-proxy -p 3452 https://eureka.imedidata.com/ 21 | ``` 22 | 23 | This will launch a rack server, listening on port 3452. 24 | When a request is made to this server on a particular path - say `http://localhost:3452/v1/apis`, then mauth-proxy will make a mauth-signed request to `https://eureka.imedidata.com/v1/apis`, then authenticate the response and return that response to the original request. 25 | 26 | Browser proxy mode: 27 | ``` 28 | $ bundle exec mauth-proxy -p 3452 --browser_proxy http://localhost:3000 http://localhost:9292 29 | ``` 30 | 31 | For this mode, add localhost:3452 in your browser's proxy configuration and access the service you want to use. 32 | If the beginning of the requested URL matches one of the URLs you specified, it will be signed and authenticated. 33 | 34 | 35 | ## Options 36 | 37 | The location of the mauth configuration can be specified or infered automatically, see the [MAuth-Client CLI Tool doc](./mauth-client_CLI.md#configuration) for more details. 38 | 39 | The last command-line argument MUST be a target URI to which requests will be forwarded. 40 | 41 | The `--no-authenticate` option disables response authentication from the target service. 42 | 43 | The `--browser_proxy` option switches to browser proxy mode and is intended to be used when the proxy is used in conjunction with a web browser that is set to use this proxy. 44 | 45 | The `--header` Accepts a [key]:[value] header definition to include, e.g. -h "Accept:application/json". It can be used multiple times for multiple headers. 46 | 47 | All other options are passed along to rack. 48 | Available options can be viewed by running rackup -h, and are also listed below: 49 | 50 | ``` 51 | Ruby options: 52 | -e, --eval LINE evaluate a LINE of code 53 | -d, --debug set debugging flags (set $DEBUG to true) 54 | -w, --warn turn warnings on for your script 55 | -I, --include PATH specify $LOAD_PATH (may be used more than once) 56 | -r, --require LIBRARY require the library, before executing your script 57 | 58 | Rack options: 59 | -s, --server SERVER serve using SERVER (webrick/mongrel) 60 | -o, --host HOST listen on HOST (default: 0.0.0.0) 61 | -p, --port PORT use PORT (default: 9292) 62 | -O NAME[=VALUE], pass VALUE to the server as option NAME. If no VALUE, sets it to true. Run 'rackup -s SERVER -h' to get a list of options for SERVER 63 | --option 64 | -E, --env ENVIRONMENT use ENVIRONMENT for defaults (default: development) 65 | -D, --daemonize run daemonized in the background 66 | -P, --pid FILE file to store PID (default: rack.pid) 67 | 68 | Common options: 69 | -h, -?, --help Show this message 70 | --version Show version 71 | ``` 72 | -------------------------------------------------------------------------------- /spec/test_suite_parser.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # file to handle loading and parsing of mauth protocol test suite cases in order 4 | # to run them as rpsec tests 5 | 6 | require 'mauth/client' 7 | require 'faraday' 8 | 9 | module ProtocolHelper 10 | TEST_SUITE_SUBMODULE_PATH = 'spec/fixtures/mauth-protocol-test-suite' 11 | CASE_PATH = "#{TEST_SUITE_SUBMODULE_PATH}/protocols" 12 | 13 | class Config 14 | class << self 15 | attr_reader :request_time, :app_uuid, :mauth_client, :pub_key 16 | 17 | def load 18 | config_hash = JSON.parse(File.read("#{TEST_SUITE_SUBMODULE_PATH}/signing-config.json")) 19 | @request_time = config_hash['request_time'] 20 | @app_uuid = config_hash['app_uuid'] 21 | @mauth_client = MAuth::Client.new( 22 | app_uuid: @app_uuid, 23 | private_key_file: File.join(TEST_SUITE_SUBMODULE_PATH, config_hash['private_key_file']) 24 | ) 25 | @pub_key = File.read("#{TEST_SUITE_SUBMODULE_PATH}/signing-params/rsa-key-pub") 26 | end 27 | 28 | def cases(protocol) 29 | Dir.children("#{CASE_PATH}/#{protocol}") 30 | end 31 | end 32 | end 33 | 34 | class CaseParser 35 | def initialize(protocol, case_name) 36 | @protocol = protocol 37 | @case_name = case_name 38 | end 39 | 40 | def req_attrs 41 | @req_attrs ||= JSON.parse(File.read(file_by_ext('req'))).tap do |attrs| 42 | if attrs.key?('body_filepath') 43 | attrs['body'] = File.read("#{CASE_PATH}/#{protocol}/#{case_name}/#{attrs['body_filepath']}") 44 | end 45 | end 46 | end 47 | 48 | def sts 49 | File.read(file_by_ext('sts')) 50 | end 51 | 52 | def sig 53 | File.read(file_by_ext('sig')) 54 | end 55 | 56 | def auth_headers 57 | JSON.parse(File.read(file_by_ext('authz'))) 58 | end 59 | 60 | private 61 | 62 | attr_reader :protocol, :case_name 63 | 64 | def file_by_ext(ext) 65 | Dir.glob("#{CASE_PATH}/#{protocol}/#{case_name}/*#{ext}").first 66 | end 67 | end 68 | 69 | # utility to help write new cases 70 | class CaseWriter 71 | def initialize(protocol, case_name) 72 | ProtocolHelper::Config.load 73 | @protocol = protocol 74 | @case_name = case_name 75 | end 76 | 77 | def build_case(attrs) 78 | @req_attrs = attrs 79 | Dir.mkdir("#{CASE_PATH}/#{protocol}/#{case_name}") 80 | write_req 81 | write_sts 82 | write_sig 83 | write_authz 84 | end 85 | 86 | def build_case_from_sts 87 | write_sig(File.read(file_by_ext('sts'))) 88 | end 89 | 90 | private 91 | 92 | attr_reader :protocol, :case_name, :req_attrs 93 | 94 | def write_req 95 | write_file('req', JSON.pretty_generate(req_attrs)) 96 | end 97 | 98 | def req 99 | faraday_env = { 100 | method: req_attrs['verb'], 101 | url: URI(req_attrs['url']), 102 | body: req_attrs['body'] 103 | } 104 | 105 | MAuth::Faraday::Request.new(faraday_env) 106 | end 107 | 108 | def sts 109 | signing_info = { 110 | app_uuid: ProtocolHelper::Config.app_uuid, 111 | time: ProtocolHelper::Config.request_time 112 | } 113 | req.string_to_sign_v2(signing_info) 114 | end 115 | 116 | def write_sts 117 | write_file('sts', sts) 118 | end 119 | 120 | def sig(given_sts) 121 | mc = ProtocolHelper::Config.mauth_client 122 | mc.signature_v2(given_sts || sts) 123 | end 124 | 125 | def write_sig(given_sts = nil) 126 | write_file('sig', sig(given_sts)) 127 | end 128 | 129 | def auth_headers 130 | mc = ProtocolHelper::Config.mauth_client 131 | mc.signed_headers_v2(req, time: ProtocolHelper::Config.request_time) 132 | end 133 | 134 | def write_authz 135 | write_file('authz', JSON.pretty_generate(auth_headers)) 136 | end 137 | 138 | def write_file(ext, contents) 139 | File.write("#{CASE_PATH}/#{protocol}/#{case_name}/#{case_name}.#{ext}", contents) 140 | end 141 | 142 | def file_by_ext(ext) 143 | Dir.glob("#{CASE_PATH}/#{protocol}/#{case_name}/*#{ext}").first 144 | end 145 | end 146 | end 147 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/gem_tasks' 4 | require 'rspec/core/rake_task' 5 | require 'mauth/request_and_response' 6 | require 'mauth/client' 7 | require 'securerandom' 8 | require 'benchmark/ips' 9 | require 'faraday' 10 | require 'rspec/mocks/standalone' 11 | 12 | RSpec::Core::RakeTask.new(:spec) 13 | 14 | task default: :spec 15 | 16 | class TestSignableRequest < MAuth::Request 17 | include MAuth::Signed 18 | attr_accessor :headers 19 | 20 | def merge_headers(headers) 21 | self.class.new(@attributes_for_signing).tap { |r| r.headers = (@headers || {}).merge(headers) } 22 | end 23 | 24 | def x_mws_time 25 | headers['X-MWS-Time'] 26 | end 27 | 28 | def x_mws_authentication 29 | headers['X-MWS-Authentication'] 30 | end 31 | 32 | def mcc_time 33 | headers['MCC-Time'] 34 | end 35 | 36 | def mcc_authentication 37 | headers['MCC-Authentication'] 38 | end 39 | end 40 | 41 | desc 'Runs benchmarks for the library.' 42 | task :benchmark do # rubocop:disable Metrics/BlockLength 43 | private_key = OpenSSL::PKey::RSA.generate(2048) 44 | public_key = private_key.public_key 45 | app_uuid = SecureRandom.uuid 46 | 47 | mc = MAuth::Client.new( 48 | private_key: private_key, 49 | app_uuid: app_uuid, 50 | v2_only_sign_requests: false, 51 | mauth_baseurl: 'http://whatever', 52 | mauth_api_version: 'v1' 53 | ) 54 | 55 | stubs = Faraday::Adapter::Test::Stubs.new 56 | test_faraday = Faraday.new do |builder| 57 | builder.adapter(:test, stubs) 58 | end 59 | stubs.post('/mauth/v1/authentication_tickets.json') { [204, {}, []] } 60 | stubs.get("/mauth/v1/security_tokens/#{app_uuid}.json") do 61 | [200, {}, JSON.generate({ 'security_token' => { 'public_key_str' => public_key.to_s } })] 62 | end 63 | allow(Faraday).to receive(:new).and_return(test_faraday) 64 | 65 | short_body = 'Somewhere in La Mancha, in a place I do not care to remember' 66 | average_body = short_body * 1_000 67 | huge_body = average_body * 100 68 | 69 | qs = 'don=quixote&quixote=don' 70 | 71 | puts <<-MSG 72 | 73 | A short request has a body of 60 chars. 74 | An average request has a body of 60,000 chars. 75 | A huge request has a body of 6,000,000 chars. 76 | A qs request has a body of 60 chars and a query string with two k/v pairs. 77 | 78 | MSG 79 | 80 | short_request = TestSignableRequest.new(verb: 'PUT', request_url: '/', body: short_body) 81 | qs_request = TestSignableRequest.new(verb: 'PUT', request_url: '/', body: short_body, query_string: qs) 82 | average_request = TestSignableRequest.new(verb: 'PUT', request_url: '/', body: average_body) 83 | huge_request = TestSignableRequest.new(verb: 'PUT', request_url: '/', body: huge_body) 84 | 85 | v1_short_signed_request = mc.signed_v1(short_request) 86 | v1_average_signed_request = mc.signed_v1(average_request) 87 | v1_huge_signed_request = mc.signed_v1(huge_request) 88 | 89 | v2_short_signed_request = mc.signed_v2(short_request) 90 | v2_qs_signed_request = mc.signed_v1(qs_request) 91 | v2_average_signed_request = mc.signed_v2(average_request) 92 | v2_huge_signed_request = mc.signed_v1(huge_request) 93 | 94 | Benchmark.ips do |bm| 95 | bm.report('v1-sign-short') { mc.signed_v1(short_request) } 96 | bm.report('v2-sign-short') { mc.signed_v2(short_request) } 97 | bm.report('both-sign-short') { mc.signed(short_request) } 98 | bm.report('v2-sign-qs') { mc.signed_v2(qs_request) } 99 | bm.report('both-sign-qs') { mc.signed(qs_request) } 100 | bm.report('v1-sign-average') { mc.signed_v1(average_request) } 101 | bm.report('v2-sign-average') { mc.signed_v2(average_request) } 102 | bm.report('both-sign-average') { mc.signed(average_request) } 103 | bm.report('v1-sign-huge') { mc.signed_v1(huge_request) } 104 | bm.report('v2-sign-huge') { mc.signed_v2(huge_request) } 105 | bm.report('both-sign-huge') { mc.signed(huge_request) } 106 | bm.compare! 107 | end 108 | 109 | puts "i/s means the number of signatures of a message per second.\n\n\n" 110 | 111 | Benchmark.ips do |bm| 112 | bm.report('v1-authenticate-short') { mc.authentic?(v1_short_signed_request) } 113 | bm.report('v2-authenticate-short') { mc.authentic?(v2_short_signed_request) } 114 | bm.report('v2-authenticate-qs') { mc.authentic?(v2_qs_signed_request) } 115 | bm.report('v1-authenticate-average') { mc.authentic?(v1_average_signed_request) } 116 | bm.report('v2-authenticate-average') { mc.authentic?(v2_average_signed_request) } 117 | bm.report('v1-authenticate-huge') { mc.authentic?(v1_huge_signed_request) } 118 | bm.report('v2-authenticate-huge') { mc.authentic?(v2_huge_signed_request) } 119 | bm.compare! 120 | end 121 | 122 | puts 'i/s means the number of authentication checks of signatures per second.' 123 | end 124 | -------------------------------------------------------------------------------- /spec/protocol_test_suite_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_suite_parser' 4 | require 'faraday' 5 | require 'mauth/client' 6 | 7 | describe 'MAuth Client passes the protocol test suite' do 8 | let(:app_uuid) { ProtocolHelper::Config.app_uuid } 9 | let(:pub_key) { ProtocolHelper::Config.pub_key } 10 | let(:request_time) { ProtocolHelper::Config.request_time } 11 | let(:mauth_client) { ProtocolHelper::Config.mauth_client } 12 | let(:signing_info) { { app_uuid: app_uuid, time: request_time } } 13 | 14 | before(:all) { ProtocolHelper::Config.load } 15 | 16 | let(:parser) { ProtocolHelper::CaseParser.new(protocol, case_dir) } 17 | let(:req_attrs) { parser.req_attrs } 18 | # must have protocol and domain name so URI won't consider `//example//` test case a 19 | # relative uri. Without protocol and domain here URI('//example//').path => '//' 20 | let(:uri_obj) { URI("https://example.com#{req_attrs['url']}") } 21 | let(:expected_str_to_sign) { parser.sts } 22 | let(:expected_signature) { parser.sig } 23 | let(:expected_auth_headers) { parser.auth_headers } 24 | let(:body) { parser.req_attrs['body'] } 25 | let(:faraday_env) do 26 | { 27 | method: req_attrs['verb'], 28 | url: uri_obj, 29 | body: body 30 | } 31 | end 32 | let(:faraday_req) { MAuth::Faraday::Request.new(faraday_env) } 33 | 34 | let(:path) { req_attrs['url'].split('?')[0] } 35 | let(:query) { req_attrs['url'].split('?')[1].to_s } 36 | let(:rackified_auth_headers) do 37 | expected_auth_headers.transform_keys { |k| k.upcase.tr('-', '_').prepend('HTTP_') } 38 | end 39 | let(:mock_rack_env) do 40 | { 41 | 'REQUEST_METHOD' => req_attrs['verb'], 42 | 'PATH_INFO' => path, 43 | 'QUERY_STRING' => query, 44 | 'rack.input' => double('rack.input', rewind: nil, read: body) 45 | }.merge(rackified_auth_headers) 46 | end 47 | let(:rack_req) { MAuth::Rack::Request.new(mock_rack_env) } 48 | 49 | describe 'MWS protocol' do 50 | let(:protocol) { 'MWS' } 51 | 52 | ProtocolHelper::Config.cases('MWS').each do |case_dir| 53 | context case_dir.to_s do 54 | let(:case_dir) { case_dir.to_s } 55 | 56 | context 'signing' do 57 | unless case_dir.include?('binary-body') 58 | it 'generates the corect string to sign' do 59 | elements = faraday_req.string_to_sign_v1(signing_info).split("\n") 60 | expected_elements = expected_str_to_sign.split("\n") 61 | 62 | elements.zip(expected_elements).each do |generated_sts_element, expected_sts_element| 63 | expect(generated_sts_element).to eq(expected_sts_element) 64 | end 65 | expect(faraday_req.string_to_sign_v1(signing_info)).to eq(expected_str_to_sign) 66 | end 67 | 68 | it 'generates the correct signature' do 69 | expect(mauth_client.signature_v1(expected_str_to_sign)).to eq(expected_signature) 70 | end 71 | end 72 | 73 | it 'generates the correct authentication headers' do 74 | expect(mauth_client.signed_headers_v1(faraday_req, time: request_time)).to eq(expected_auth_headers) 75 | end 76 | end 77 | 78 | context 'authentication' do 79 | before do 80 | allow(Time).to receive(:now).and_return(Time.at(request_time)) 81 | allow(mauth_client).to receive(:retrieve_public_key).and_return(pub_key) 82 | end 83 | 84 | it 'considers the authentically-signed request to be authentic' do 85 | expect { mauth_client.authenticate!(rack_req) }.not_to raise_error 86 | end 87 | end 88 | end 89 | end 90 | 91 | describe 'MWSV2 protocol' do 92 | let(:protocol) { 'MWSV2' } 93 | 94 | ProtocolHelper::Config.cases('MWSV2').each do |case_dir| 95 | context case_dir.to_s do 96 | let(:case_dir) { case_dir.to_s } 97 | 98 | unless case_dir.include?('authentication-only') 99 | context 'signing' do 100 | it 'generates the corect string to sign' do 101 | elements = faraday_req.string_to_sign_v2(signing_info).split("\n") 102 | expected_elements = expected_str_to_sign.split("\n") 103 | 104 | elements.zip(expected_elements).each do |generated_sts_element, expected_sts_element| 105 | expect(generated_sts_element).to eq(expected_sts_element) 106 | end 107 | expect(faraday_req.string_to_sign_v2(signing_info)).to eq(expected_str_to_sign) 108 | end 109 | 110 | it 'generates the correct signature' do 111 | expect(mauth_client.signature_v2(expected_str_to_sign)).to eq(expected_signature) 112 | end 113 | 114 | it 'generates the correct authentication headers' do 115 | expect(mauth_client.signed_headers_v2(faraday_req, time: request_time)).to eq(expected_auth_headers) 116 | end 117 | end 118 | end 119 | 120 | context 'authentication' do 121 | before do 122 | allow(Time).to receive(:now).and_return(Time.at(request_time)) 123 | allow(mauth_client).to receive(:retrieve_public_key).and_return(pub_key) 124 | end 125 | 126 | it 'considers the authentically-signed request to be authentic' do 127 | expect { mauth_client.authenticate!(rack_req) }.not_to raise_error 128 | end 129 | end 130 | end 131 | end 132 | end 133 | end 134 | end 135 | -------------------------------------------------------------------------------- /spec/client/security_token_cacher_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'faraday' 5 | require 'mauth/client' 6 | 7 | describe MAuth::Client::Authenticator::SecurityTokenCacher do 8 | subject { described_class.new(client) } 9 | let(:client) do 10 | MAuth::Client.new( 11 | mauth_baseurl: 'http://whatever', 12 | mauth_api_version: 'v1', 13 | private_key: OpenSSL::PKey::RSA.generate(2048), 14 | app_uuid: 'authenticator', 15 | use_rails_cache: use_rails_cache 16 | ) 17 | end 18 | let(:use_rails_cache) { false } 19 | 20 | describe '#get' do 21 | let(:service_app_uuid) { '077dcb2b-f476-4069-adf4-f75c15018d65' } 22 | let(:signing_key) { OpenSSL::PKey::RSA.generate(2048) } 23 | let(:status) { 200 } 24 | let(:headers) { {} } 25 | let(:security_token) { { 'security_token' => { 'public_key_str' => signing_key.public_key.to_s } } } 26 | let(:response_body) { JSON.generate(security_token) } 27 | 28 | before do 29 | stub_request(:get, "http://whatever/mauth/v1/security_tokens/#{service_app_uuid}.json").to_return( 30 | status: status, 31 | headers: headers, 32 | body: response_body 33 | ) 34 | end 35 | 36 | shared_examples_for 'Faraday errors' do |faraday_error| 37 | before do 38 | allow_any_instance_of(Faraday::Connection).to receive(:get).and_raise(faraday_error.new('')) 39 | end 40 | 41 | it 'logs and raises UnableToAuthenticateError' do 42 | expect(client.logger).to receive(:error) 43 | .with(/Unable to authenticate with MAuth. Exception mAuth service did not respond; received/) 44 | expect { subject.get(service_app_uuid) }.to raise_error(MAuth::UnableToAuthenticateError) 45 | end 46 | end 47 | 48 | context 'when response status is 200' do 49 | it 'returns security_token' do 50 | expect(subject.get(service_app_uuid)).to eq(security_token) 51 | end 52 | end 53 | 54 | context 'malicious app_uuid' do 55 | let(:service_app_uuid) { "!#{$&}'()*+,/:;=?@[]" } 56 | let(:escaped_app_uuid) { '%21%27%28%29%2A%2B%2C%2F%3A%3B%3D%3F%40%5B%5D' } 57 | 58 | before do 59 | stub_request(:get, "http://whatever/mauth/v1/security_tokens/#{escaped_app_uuid}.json").to_return( 60 | status: status, 61 | headers: headers, 62 | body: response_body 63 | ) 64 | end 65 | 66 | it 'escapes app_uuid' do 67 | expect_any_instance_of(Faraday::Connection) 68 | .to receive(:get).with("/mauth/v1/security_tokens/#{escaped_app_uuid}.json") 69 | .and_call_original 70 | 71 | subject.get(service_app_uuid) 72 | end 73 | end 74 | 75 | context 'when faraday error occurs' do 76 | include_examples 'Faraday errors', Faraday::ConnectionFailed 77 | include_examples 'Faraday errors', Faraday::TimeoutError 78 | end 79 | 80 | context 'when response body is not JSON' do 81 | let(:response_body) { 'plain text' } 82 | 83 | it 'logs and raises UnableToAuthenticateError' do 84 | expect(client.logger).to receive(:error) 85 | .with(/Unable to authenticate with MAuth. Exception mAuth service responded with unparseable json/) 86 | expect { subject.get(service_app_uuid) }.to raise_error(MAuth::UnableToAuthenticateError) 87 | end 88 | end 89 | 90 | context 'when response status is 404' do 91 | let(:status) { 404 } 92 | 93 | it 'raises InauthenticError' do 94 | expect { subject.get(service_app_uuid) } 95 | .to raise_error( 96 | MAuth::InauthenticError, 97 | "mAuth service responded with 404 looking up public key for #{service_app_uuid}" 98 | ) 99 | end 100 | end 101 | 102 | context 'when response status is not 404' do 103 | let(:status) { 500 } 104 | 105 | it 'raises UnableToAuthenticateError' do 106 | expect { subject.get(service_app_uuid) }.to raise_error(MAuth::UnableToAuthenticateError) 107 | end 108 | end 109 | 110 | describe 'caching' do 111 | let(:headers) do 112 | { 113 | 'Cache-Control' => 'max-age=300, private', 114 | 'ETag' => 'W"e9d1e3499087ff67e169e9ee0034f5c9' 115 | } 116 | end 117 | 118 | before do 119 | Timecop.freeze(Time.now) 120 | 121 | stub_request(:get, "http://whatever/mauth/v1/security_tokens/#{service_app_uuid}.json").to_return( 122 | status: status, 123 | headers: headers, 124 | body: response_body 125 | ).then.to_return( 126 | status: status, 127 | body: '{}' 128 | ) 129 | end 130 | 131 | after do 132 | Timecop.return 133 | end 134 | 135 | it 'caches the response' do 136 | expect(subject.get(service_app_uuid)).to eq(security_token) 137 | expect(subject.get(service_app_uuid)).to eq(security_token) 138 | end 139 | 140 | it 'retrives again once the cache is expired' do 141 | expect(subject.get(service_app_uuid)).to eq(security_token) 142 | Timecop.freeze(Time.now + 301) 143 | expect(subject.get(service_app_uuid)).to be_empty 144 | end 145 | 146 | context 'with Rails.cache' do 147 | let(:use_rails_cache) { true } 148 | let(:rails_cache) { double(read: nil, write: nil, delete: nil) } 149 | 150 | before do 151 | stub_const('Rails', double(cache: rails_cache, logger: Logger.new($stderr))) 152 | end 153 | 154 | it 'uses Rails.cache' do 155 | expect(rails_cache).to receive(:read) 156 | expect(rails_cache).to receive(:write) 157 | expect(subject.get(service_app_uuid)).to eq(security_token) 158 | end 159 | end 160 | end 161 | end 162 | end 163 | -------------------------------------------------------------------------------- /spec/client_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'faraday' 5 | require 'mauth/client' 6 | require 'securerandom' 7 | require_relative 'support/shared_contexts/client' 8 | 9 | describe MAuth::Client do 10 | include_context 'client' 11 | 12 | describe '#initialize' do 13 | it 'initializes without config' do 14 | MAuth::Client.new 15 | end 16 | 17 | require 'logger' 18 | config_pieces = { 19 | logger: Logger.new($stderr), 20 | mauth_baseurl: 'https://mauth.imedidata.net', 21 | mauth_api_version: 'v1' 22 | } 23 | config_pieces.each do |config_key, value| 24 | it "initializes with #{config_key}" do 25 | # set with a string 26 | mc = MAuth::Client.new(config_key.to_s => value) 27 | # check the accessor method 28 | expect(value).to eq(mc.send(config_key)) 29 | # set with a symbol 30 | mc = MAuth::Client.new(config_key.to_s => value) 31 | # check the accossor method 32 | expect(value).to eq(mc.send(config_key)) 33 | end 34 | end 35 | 36 | it 'logs to Rails.logger if it can' do 37 | Object.const_set(:Rails, Object.new) 38 | def (Rails).logger 39 | @logger ||= Logger.new($stderr) 40 | end 41 | expect(Rails.logger).to eq(MAuth::Client.new.logger) 42 | Object.send(:remove_const, 'Rails') 43 | end 44 | 45 | it 'builds a logger if Rails is defined, but Rails.logger is nil' do 46 | Object.const_set(:Rails, Object.new) 47 | def (Rails).logger 48 | nil 49 | end 50 | logger = double('logger') 51 | allow(Logger).to receive(:new).with(anything).and_return(logger) 52 | expect(logger).to eq(MAuth::Client.new.logger) 53 | Object.send(:remove_const, 'Rails') 54 | end 55 | 56 | it 'initializes with app_uuid' do 57 | uuid = '40e19273-6a43-41d1-ba71-71cbb1b69d35' 58 | [{ app_uuid: uuid }, { 'app_uuid' => uuid }].each do |config| 59 | mc = MAuth::Client.new(config) 60 | expect(uuid).to eq(mc.client_app_uuid) 61 | end 62 | end 63 | 64 | it 'initializes with ssl_cert_path' do 65 | ssl_certs_path = 'ssl/certs/path' 66 | [{ ssl_certs_path: ssl_certs_path }, { 'ssl_certs_path' => ssl_certs_path }].each do |config| 67 | mc = MAuth::Client.new(config) 68 | expect(ssl_certs_path).to eq(mc.ssl_certs_path) 69 | end 70 | end 71 | 72 | it 'initializes with private key' do 73 | key = OpenSSL::PKey::RSA.generate(2048) 74 | [{ private_key: key }, { 'private_key' => key }, { private_key: key.to_s }, 75 | { 'private_key' => key.to_s }].each do |config| 76 | mc = MAuth::Client.new(config) 77 | # can't directly compare the OpenSSL::PKey::RSA instances 78 | expect(key.class).to eq(mc.private_key.class) 79 | expect(key.to_s).to eq(mc.private_key.to_s) 80 | end 81 | end 82 | 83 | it 'correctly initializes with v2_only_authenticate as true with boolean true or string "true"' do 84 | [true, 'true', 'TRUE'].each do |val| 85 | [{ v2_only_authenticate: val }, { 'v2_only_authenticate' => val }].each do |config| 86 | mc = MAuth::Client.new(config) 87 | expect(mc.v2_only_authenticate?).to eq(true) 88 | end 89 | end 90 | end 91 | 92 | it 'correctly initializes with v2_only_authenticate as false with any other values' do 93 | ['tru', false, 'false', 1, 0, nil, ''].each do |val| 94 | [{ v2_only_authenticate: val }, { 'v2_only_authenticate' => val }].each do |config| 95 | mc = MAuth::Client.new(config) 96 | expect(mc.v2_only_authenticate?).to eq(false) 97 | end 98 | end 99 | end 100 | 101 | it 'correctly initializes with v2_only_sign_requests as true with boolean true or string "true"' do 102 | [true, 'true', 'TRUE'].each do |val| 103 | [{ v2_only_sign_requests: val }, { 'v2_only_sign_requests' => val }].each do |config| 104 | mc = MAuth::Client.new(config) 105 | expect(mc.v2_only_sign_requests?).to eq(true) 106 | end 107 | end 108 | end 109 | 110 | it 'correctly initializes with v2_only_sign_requests as false with any other values' do 111 | ['tru', false, 'false', 1, 0, nil].each do |val| 112 | [{ v2_only_sign_requests: val }, { 'v2_only_sign_requests' => val }].each do |config| 113 | mc = MAuth::Client.new(config) 114 | expect(mc.v2_only_sign_requests?).to eq(false) 115 | end 116 | end 117 | end 118 | 119 | it 'correctly initializes with disable_fallback_to_v1_on_v2_failure as true with boolean true or string "true"' do 120 | [true, 'true', 'TRUE'].each do |val| 121 | [{ disable_fallback_to_v1_on_v2_failure: val }, 122 | { 'disable_fallback_to_v1_on_v2_failure' => val }].each do |config| 123 | mc = MAuth::Client.new(config) 124 | expect(mc.disable_fallback_to_v1_on_v2_failure?).to eq(true) 125 | end 126 | end 127 | end 128 | 129 | it 'correctly initializes with disable_fallback_to_v1_on_v2_failure as false with any other values' do 130 | ['tru', false, 'false', 1, 0, nil, ''].each do |val| 131 | [{ disable_fallback_to_v1_on_v2_failure: val }, 132 | { 'disable_fallback_to_v1_on_v2_failure' => val }].each do |config| 133 | mc = MAuth::Client.new(config) 134 | expect(mc.disable_fallback_to_v1_on_v2_failure?).to eq(false) 135 | end 136 | end 137 | end 138 | 139 | it 'correctly initializes with v1_only_sign_requests as true with boolean true or string "true"' do 140 | [true, 'true', 'TRUE'].each do |val| 141 | [{ v1_only_sign_requests: val }, { 'v1_only_sign_requests' => val }].each do |config| 142 | mc = MAuth::Client.new(config) 143 | expect(mc.v1_only_sign_requests?).to eq(true) 144 | end 145 | end 146 | end 147 | 148 | it 'correctly initializes with v1_only_sign_requests as false with any other values' do 149 | ['tru', false, 'false', 1, 0, nil, ''].each do |val| 150 | [{ v1_only_sign_requests: val }, { 'v1_only_sign_requests' => val }].each do |config| 151 | mc = MAuth::Client.new(config) 152 | expect(mc.v1_only_sign_requests?).to eq(false) 153 | end 154 | end 155 | end 156 | 157 | it 'raises an error if both v1_only_sign_requests and v2_only_sign_requests are set to true' do 158 | config = { v1_only_sign_requests: true, v2_only_sign_requests: true } 159 | expect { MAuth::Client.new(config) }.to raise_error(MAuth::Client::ConfigurationError) 160 | end 161 | end 162 | end 163 | -------------------------------------------------------------------------------- /lib/mauth/rack.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'mauth/middleware' 4 | require 'mauth/request_and_response' 5 | require 'rack/utils' 6 | 7 | module MAuth 8 | module Rack 9 | # middleware which will check that a request is authentically signed. 10 | # 11 | # if the request is checked and is not authentic, 401 Unauthorized is returned 12 | # and the app is not called. 13 | # 14 | # options accepted (key may be string or symbol) 15 | # - should_authenticate_check: a proc which should accept a rack env as an argument, 16 | # and return true if the request should be authenticated; false if not. if the result 17 | # from this is false, the request is passed to the app with no authentication performed. 18 | class RequestAuthenticator < MAuth::Middleware 19 | def call(env) 20 | mauth_request = MAuth::Rack::Request.new(env) 21 | env['mauth.protocol_version'] = mauth_request.protocol_version 22 | 23 | return @app.call(env) unless should_authenticate?(env) 24 | 25 | if mauth_client.v2_only_authenticate? && mauth_request.protocol_version == 1 26 | return response_for_missing_v2(env) 27 | end 28 | 29 | begin 30 | if mauth_client.authentic?(mauth_request) 31 | @app.call(env.merge!( 32 | MAuth::Client::RACK_ENV_APP_UUID_KEY => mauth_request.signature_app_uuid, 33 | 'mauth.authentic' => true 34 | )) 35 | else 36 | response_for_inauthentic_request(env) 37 | end 38 | rescue MAuth::UnableToAuthenticateError 39 | response_for_unable_to_authenticate(env) 40 | end 41 | end 42 | 43 | # discards the body if REQUEST_METHOD is HEAD. sets the Content-Length. 44 | def handle_head(env) 45 | status, headers, body = *yield 46 | headers['Content-Length'] = body.sum(&:bytesize).to_s 47 | [status, headers, env['REQUEST_METHOD'].casecmp('head').zero? ? [] : body] 48 | end 49 | 50 | # whether the request needs to be authenticated 51 | def should_authenticate?(env) 52 | @config['should_authenticate_check'] ? @config['should_authenticate_check'].call(env) : true 53 | end 54 | 55 | # response when the request is inauthentic. responds with status 401 Unauthorized and a 56 | # message. 57 | def response_for_inauthentic_request(env) 58 | handle_head(env) do 59 | body = { 'errors' => { 'mauth' => ['Unauthorized'] } } 60 | [401, { 'Content-Type' => 'application/json' }, [JSON.pretty_generate(body)]] 61 | end 62 | end 63 | 64 | # response when the authenticity of the request cannot be determined, due to 65 | # a problem communicating with the MAuth service. responds with a status of 500 and 66 | # a message. 67 | def response_for_unable_to_authenticate(env) 68 | handle_head(env) do 69 | body = { 'errors' => { 'mauth' => ['Could not determine request authenticity'] } } 70 | [500, { 'Content-Type' => 'application/json' }, [JSON.pretty_generate(body)]] 71 | end 72 | end 73 | 74 | # response when the requests includes V1 headers but does not include V2 75 | # headers and the V2_ONLY_AUTHENTICATE flag is set. 76 | def response_for_missing_v2(env) 77 | handle_head(env) do 78 | body = { 79 | 'type' => 'errors:mauth:missing_v2', 80 | 'title' => 'This service requires mAuth v2 mcc-authentication header. Upgrade your mAuth library and ' \ 81 | 'configure it properly.' 82 | } 83 | [401, { 'Content-Type' => 'application/json' }, [JSON.pretty_generate(body)]] 84 | end 85 | end 86 | end 87 | 88 | # same as MAuth::Rack::RequestAuthenticator, but does not authenticate /app_status 89 | class RequestAuthenticatorNoAppStatus < RequestAuthenticator 90 | def should_authenticate?(env) 91 | env['PATH_INFO'] != '/app_status' && super 92 | end 93 | end 94 | 95 | # signs outgoing responses with only the protocol used to sign the request. 96 | class ResponseSigner < MAuth::Middleware 97 | def call(env) 98 | unsigned_response = @app.call(env) 99 | 100 | method = 101 | case env['mauth.protocol_version'] 102 | when 2 103 | :signed_v2 104 | when 1 105 | :signed_v1 106 | else 107 | # if no protocol was supplied then use `signed` which either signs 108 | # with both protocol versions (by default) or only v2 when the 109 | # v2_only_sign_requests flag is set to true. 110 | :signed 111 | end 112 | response = mauth_client.send(method, MAuth::Rack::Response.new(*unsigned_response)) 113 | response.status_headers_body 114 | end 115 | end 116 | 117 | # representation of a request composed from a rack request env which can be passed to a 118 | # Mauth::Client for authentication 119 | class Request < MAuth::Request 120 | include Signed 121 | attr_reader :env 122 | 123 | def initialize(env) 124 | @env = env 125 | end 126 | 127 | def attributes_for_signing 128 | @attributes_for_signing ||= begin 129 | env['rack.input'].rewind 130 | body = env['rack.input'].read 131 | env['rack.input'].rewind 132 | { 133 | verb: env['REQUEST_METHOD'], 134 | request_url: env['PATH_INFO'], 135 | body: body, 136 | query_string: env['QUERY_STRING'] 137 | } 138 | end 139 | end 140 | 141 | def x_mws_time 142 | @env['HTTP_X_MWS_TIME'] 143 | end 144 | 145 | def x_mws_authentication 146 | @env['HTTP_X_MWS_AUTHENTICATION'] 147 | end 148 | 149 | def mcc_time 150 | @env['HTTP_MCC_TIME'] 151 | end 152 | 153 | def mcc_authentication 154 | @env['HTTP_MCC_AUTHENTICATION'] 155 | end 156 | end 157 | 158 | # representation of a response composed from a rack response (status, headers, body) which 159 | # can be passed to a Mauth::Client for signing 160 | class Response < MAuth::Response 161 | def initialize(status, headers, body) 162 | @status = status 163 | @headers = headers 164 | @body = body 165 | end 166 | 167 | def status_headers_body 168 | [@status, @headers, @body] 169 | end 170 | 171 | def attributes_for_signing 172 | @attributes_for_signing ||= begin 173 | body = +'' 174 | # NOTE: rack only requires #each be defined on the body, so not using map or inject 175 | @body.each do |part| 176 | body << part 177 | end 178 | { status_code: @status.to_i, body: body } 179 | end 180 | end 181 | 182 | # takes a Hash of headers; returns an instance of this class whose 183 | # headers have been updated with the argument headers 184 | def merge_headers(headers) 185 | self.class.new(@status, @headers.merge(headers), @body) 186 | end 187 | end 188 | end 189 | end 190 | -------------------------------------------------------------------------------- /spec/client/signer_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'mauth/client' 5 | require_relative '../support/shared_contexts/client' 6 | 7 | describe MAuth::Client::Signer do 8 | include_context 'client' 9 | 10 | describe '#signed' do 11 | context 'when the v2_only_sign_requests flag is true' do 12 | let(:v2_only_sign_requests) { true } 13 | 14 | it 'adds only MCC-Time and MCC-Authentication headers when signing' do 15 | signed_request = client.signed(request) 16 | expect(signed_request.headers.keys).to include('MCC-Authentication', 'MCC-Time') 17 | expect(signed_request.headers.keys).not_to include('X-MWS-Authentication', 'X-MWS-Time') 18 | end 19 | end 20 | 21 | context 'when the v1_only_sign_requests flag is true' do 22 | let(:v1_only_sign_requests) { true } 23 | 24 | it 'adds only X-MWS-Time and X-MWS-Authentication headers when signing' do 25 | signed_request = client.signed(request) 26 | expect(signed_request.headers.keys).to include('X-MWS-Authentication', 'X-MWS-Time') 27 | expect(signed_request.headers.keys).not_to include('MCC-Authentication', 'MCC-Time') 28 | end 29 | end 30 | 31 | it 'by default adds X-MWS-Time, X-MWS-Authentication, MCC-Time, MCC-Authentication headers when signing' do 32 | signed_request = client.signed(request) 33 | expect(signed_request.headers.keys).to include('X-MWS-Authentication', 'X-MWS-Time', 'MCC-Authentication', 34 | 'MCC-Time') 35 | end 36 | 37 | it "can't sign without a private key" do 38 | mc = MAuth::Client.new(app_uuid: app_uuid) 39 | expect { mc.signed(request) }.to raise_error(MAuth::UnableToSignError) 40 | end 41 | 42 | it "can't sign without an app uuid" do 43 | mc = MAuth::Client.new(private_key: OpenSSL::PKey::RSA.generate(2048)) 44 | expect { mc.signed(request) }.to raise_error(MAuth::UnableToSignError) 45 | end 46 | end 47 | 48 | describe '#signed_v1' do 49 | it 'adds only X-MWS-Time and X-MWS-Authentication headers' do 50 | expect(v1_signed_req.headers.keys).to include('X-MWS-Authentication', 'X-MWS-Time') 51 | expect(v1_signed_req.headers.keys).not_to include('MCC-Authentication', 'MCC-Time') 52 | end 53 | end 54 | 55 | describe '#signed_v2' do 56 | it 'adds only MCC-Time and MCC-Authentication headers' do 57 | expect(v2_signed_req.headers.keys).to include('MCC-Authentication', 'MCC-Time') 58 | expect(v2_signed_req.headers.keys).not_to include('X-MWS-Authentication', 'X-MWS-Time') 59 | end 60 | end 61 | 62 | describe '#signed_headers' do 63 | context 'when the v2_only_sign_requests flag is true' do 64 | let(:v2_only_sign_requests) { true } 65 | 66 | it 'returns only MCC-Time and MCC-Authentication headers when signing' do 67 | signed_headers = client.signed_headers(request) 68 | expect(signed_headers.keys).to include('MCC-Authentication', 'MCC-Time') 69 | expect(signed_headers.keys).not_to include('X-MWS-Authentication', 'X-MWS-Time') 70 | end 71 | end 72 | 73 | context 'when the v1_only_sign_requests flag is true' do 74 | let(:v1_only_sign_requests) { true } 75 | 76 | it 'returns only X-MWS-Time and X-MWS-Authentication headers when signing' do 77 | signed_headers = client.signed_headers(request) 78 | expect(signed_headers.keys).to include('X-MWS-Authentication', 'X-MWS-Time') 79 | expect(signed_headers.keys).not_to include('MCC-Authentication', 'MCC-Time') 80 | end 81 | end 82 | 83 | it 'by default returns X-MWS-Time, X-MWS-Authentication, MCC-Time, MCC-Authentication headers' do 84 | signed_headers = client.signed_headers(request) 85 | expect(signed_headers.keys).to include('X-MWS-Authentication', 'X-MWS-Time', 'MCC-Authentication', 'MCC-Time') 86 | end 87 | end 88 | 89 | describe '#signed_headers_v1' do 90 | it 'returns only X-MWS-Time and X-MWS-Authentication headers' do 91 | signed_headers = client.signed_headers_v1(request) 92 | expect(signed_headers.keys).to include('X-MWS-Authentication', 'X-MWS-Time') 93 | expect(signed_headers.keys).not_to include('MCC-Authentication', 'MCC-Time') 94 | end 95 | end 96 | 97 | describe '#signed_headers_v2' do 98 | it 'returns only MCC-Time and MCC-Authentication headers' do 99 | signed_headers = client.signed_headers_v2(request) 100 | expect(signed_headers.keys).to include('MCC-Authentication', 'MCC-Time') 101 | expect(signed_headers.keys).not_to include('X-MWS-Authentication', 'X-MWS-Time') 102 | end 103 | end 104 | 105 | describe 'signature methods' do 106 | let(:string_to_sign) { 'dummy str' } 107 | let(:mock_pkey) { double('private_key', sign: 'encoded_message', private_encrypt: 'encoded') } 108 | 109 | before do 110 | allow(client).to receive(:private_key).and_return(mock_pkey) 111 | end 112 | 113 | describe '#signature_v1' do 114 | it 'base 64 encodes the signed digest' do 115 | signature = client.signature_v1(string_to_sign) 116 | expect(Base64.decode64(signature)).to eq('encoded') 117 | end 118 | 119 | it 'handles newlines appropriately' do 120 | signature = client.signature_v1(string_to_sign) 121 | expect(!signature.include?("\n")).to be(true) 122 | end 123 | end 124 | 125 | describe '#signature_v2' do 126 | it 'base 64 encodes the signed digest' do 127 | signature = client.signature_v2(string_to_sign) 128 | expect(Base64.decode64(signature)).to eq('encoded_message') 129 | end 130 | 131 | it 'handles newlines appropriately' do 132 | signature = client.signature_v2(string_to_sign) 133 | expect(!signature.include?("\n")).to be(true) 134 | end 135 | 136 | it 'calls `sign` with an OpenSSL SHA512 digest' do 137 | expect(mock_pkey).to receive(:sign) 138 | .with(an_instance_of(OpenSSL::Digest), string_to_sign) 139 | client.signature_v2(string_to_sign) 140 | end 141 | end 142 | end 143 | 144 | describe 'cross platform signature testing' do 145 | let(:testing_info) { JSON.parse(File.read('spec/fixtures/mauth_signature_testing.json'), symbolize_names: true) } 146 | let(:client) do 147 | MAuth::Client.new( 148 | private_key: testing_info[:private_key], 149 | app_uuid: testing_info[:app_uuid] 150 | ) 151 | end 152 | 153 | let(:request) { MAuth::Request.new(attributes_for_signing) } 154 | let(:attributes_for_signing) do 155 | testing_info[:attributes_for_signing].tap do |attributes| 156 | attributes[:body] = body 157 | end 158 | end 159 | 160 | describe 'binary body' do 161 | let(:body) { File.binread('spec/fixtures/blank.jpeg') } 162 | 163 | it 'returns accurate v1 signature' do 164 | signature_v1 = client.signature_v1(request.string_to_sign_v1({})) 165 | expect(signature_v1).to eq(testing_info[:signatures][:v1_binary]) 166 | end 167 | 168 | it 'returns accurate v2 signature' do 169 | signature_v2 = client.signature_v2(request.string_to_sign_v2({})) 170 | expect(signature_v2).to eq(testing_info[:signatures][:v2_binary]) 171 | end 172 | end 173 | 174 | describe 'empty body' do 175 | let(:body) { '' } 176 | 177 | it 'returns accurate v1 signature' do 178 | signature_v1 = client.signature_v1(request.string_to_sign_v1({})) 179 | expect(signature_v1).to eq(testing_info[:signatures][:v1_empty]) 180 | end 181 | 182 | it 'returns accurate v2 signature' do 183 | signature_v2 = client.signature_v2(request.string_to_sign_v2({})) 184 | expect(signature_v2).to eq(testing_info[:signatures][:v2_empty]) 185 | end 186 | end 187 | end 188 | end 189 | -------------------------------------------------------------------------------- /lib/mauth/client.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'uri' 4 | require 'openssl' 5 | require 'base64' 6 | require 'json' 7 | require 'yaml' 8 | require 'mauth/core_ext' 9 | require 'mauth/autoload' 10 | require 'mauth/version' 11 | require 'mauth/client/authenticator' 12 | require 'mauth/client/signer' 13 | require 'mauth/config_env' 14 | require 'mauth/errors' 15 | require 'mauth/private_key_helper' 16 | 17 | module MAuth 18 | # does operations which require a private key and corresponding app uuid. this is primarily: 19 | # - signing outgoing requests and responses 20 | # - authenticating incoming requests and responses, which may require retrieving the appropriate 21 | # public key from mAuth (which requires a request to mAuth which is signed using the private 22 | # key) 23 | # 24 | # this nominally operates on request and response objects, but really the only requirements are 25 | # that the object responds to the methods of MAuth::Signable and/or MAuth::Signed (as 26 | # appropriate) 27 | class Client 28 | MWS_TOKEN = 'MWS' 29 | MWSV2_TOKEN = 'MWSV2' 30 | AUTH_HEADER_DELIMITER = ';' 31 | RACK_ENV_APP_UUID_KEY = 'mauth.app_uuid' 32 | 33 | include Authenticator 34 | include Signer 35 | 36 | # returns a configuration (to be passed to MAuth::Client.new) which is configured from information stored in 37 | # standard places. all of which is overridable by options in case some defaults do not apply. 38 | # 39 | # options (may be symbols or strings) - any or all may be omitted where your usage conforms to the defaults. 40 | # - mauth_config - MAuth configuration. defaults to load this from environment variables. if this is specified, 41 | # no environment variable is loaded, and the given config is passed through with any other defaults applied. 42 | # at the moment, the only other default is to set the logger. 43 | # - logger - by default checks ::Rails.logger 44 | def self.default_config(options = {}) 45 | options = options.stringify_symbol_keys 46 | 47 | # find mauth config 48 | mauth_config = options['mauth_config'] || ConfigEnv.load 49 | 50 | unless mauth_config.key?('logger') 51 | # the logger. Rails.logger if it exists, otherwise, no logger 52 | mauth_config['logger'] = options['logger'] || begin 53 | if Object.const_defined?(:Rails) && ::Rails.respond_to?(:logger) 54 | Rails.logger 55 | end 56 | end 57 | end 58 | 59 | mauth_config 60 | end 61 | 62 | # new client with the given App UUID and public key. config may include the following (all 63 | # config keys may be strings or symbols): 64 | # - private_key - required for signing and for authentication. 65 | # may be given as a string or a OpenSSL::PKey::RSA instance. 66 | # - app_uuid - required in the same circumstances where a private_key is required 67 | # - mauth_baseurl - required. needed to retrieve public keys. 68 | # - mauth_api_version - required. only 'v1' exists / is supported as of this writing. 69 | # - logger - a Logger to which any useful information will be written. if this is omitted and 70 | # Rails.logger exists, that will be used. 71 | def initialize(config = {}) 72 | # stringify symbol keys 73 | given_config = config.stringify_symbol_keys 74 | # build a configuration which discards any irrelevant parts of the given config (small memory usage matters here) 75 | @config = {} 76 | if given_config['private_key_file'] && !given_config['private_key'] 77 | given_config['private_key'] = File.read(given_config['private_key_file']) 78 | end 79 | @config['private_key'] = 80 | case given_config['private_key'] 81 | when nil 82 | nil 83 | when String 84 | PrivateKeyHelper.load(given_config['private_key']) 85 | when OpenSSL::PKey::RSA 86 | given_config['private_key'] 87 | else 88 | raise MAuth::Client::ConfigurationError, 89 | "unrecognized value given for 'private_key' - this may be a " \ 90 | "String, a OpenSSL::PKey::RSA, or omitted; instead got: #{given_config['private_key'].inspect}" 91 | end 92 | @config['app_uuid'] = given_config['app_uuid'] 93 | @config['mauth_baseurl'] = given_config['mauth_baseurl'] 94 | @config['mauth_api_version'] = given_config['mauth_api_version'] 95 | @config['logger'] = given_config['logger'] || begin 96 | if Object.const_defined?(:Rails) && Rails.logger 97 | Rails.logger 98 | else 99 | require 'logger' 100 | ::Logger.new(File.open(File::NULL, File::WRONLY)) 101 | end 102 | end 103 | 104 | request_config = { timeout: 10, open_timeout: 3 } 105 | request_config.merge!(symbolize_keys(given_config['faraday_options'])) if given_config['faraday_options'] 106 | @config['faraday_options'] = { request: request_config } || {} 107 | @config['ssl_certs_path'] = given_config['ssl_certs_path'] if given_config['ssl_certs_path'] 108 | @config['v2_only_authenticate'] = given_config['v2_only_authenticate'].to_s.casecmp('true').zero? 109 | @config['v2_only_sign_requests'] = given_config['v2_only_sign_requests'].to_s.casecmp('true').zero? 110 | @config['v1_only_sign_requests'] = given_config['v1_only_sign_requests'].to_s.casecmp('true').zero? 111 | if @config['v2_only_sign_requests'] && @config['v1_only_sign_requests'] 112 | raise MAuth::Client::ConfigurationError, 'v2_only_sign_requests and v1_only_sign_requests may not both be true' 113 | end 114 | 115 | @config['disable_fallback_to_v1_on_v2_failure'] = 116 | given_config['disable_fallback_to_v1_on_v2_failure'].to_s.casecmp('true').zero? 117 | @config['use_rails_cache'] = given_config['use_rails_cache'] 118 | end 119 | 120 | def logger 121 | @config['logger'] 122 | end 123 | 124 | def client_app_uuid 125 | @config['app_uuid'] 126 | end 127 | 128 | def mauth_baseurl 129 | @config['mauth_baseurl'] || raise(MAuth::Client::ConfigurationError, 'no configured mauth_baseurl!') 130 | end 131 | 132 | def mauth_api_version 133 | @config['mauth_api_version'] || raise(MAuth::Client::ConfigurationError, 'no configured mauth_api_version!') 134 | end 135 | 136 | def private_key 137 | @config['private_key'] 138 | end 139 | 140 | def faraday_options 141 | @config['faraday_options'] 142 | end 143 | 144 | def ssl_certs_path 145 | @config['ssl_certs_path'] 146 | end 147 | 148 | def v2_only_sign_requests? 149 | @config['v2_only_sign_requests'] 150 | end 151 | 152 | def v2_only_authenticate? 153 | @config['v2_only_authenticate'] 154 | end 155 | 156 | def disable_fallback_to_v1_on_v2_failure? 157 | @config['disable_fallback_to_v1_on_v2_failure'] 158 | end 159 | 160 | def v1_only_sign_requests? 161 | @config['v1_only_sign_requests'] 162 | end 163 | 164 | def assert_private_key(err) 165 | raise err unless private_key 166 | end 167 | 168 | def cache_store 169 | Rails.cache if @config['use_rails_cache'] && Object.const_defined?(:Rails) && ::Rails.respond_to?(:cache) 170 | end 171 | 172 | private 173 | 174 | def mauth_service_response_error(response) 175 | message = "mAuth service responded with #{response.status}: #{response.body}" 176 | logger.error(message) 177 | error = UnableToAuthenticateError.new(message) 178 | error.mauth_service_response = response 179 | raise error 180 | end 181 | 182 | # Changes all keys in the top level of the hash to symbols. Does not affect nested hashes inside this one. 183 | def symbolize_keys(hash) 184 | hash.keys.each do |key| 185 | hash[(key.to_sym rescue key) || key] = hash.delete(key) 186 | end 187 | hash 188 | end 189 | end 190 | end 191 | -------------------------------------------------------------------------------- /lib/mauth/request_and_response.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'openssl' 4 | require 'addressable' 5 | 6 | module MAuth 7 | # module which composes a string to sign. 8 | # 9 | # includer must provide 10 | # - SIGNATURE_COMPONENTS OR SIGNATURE_COMPONENTS_V2 constant - array of keys to get from 11 | # - #attributes_for_signing 12 | # - #merge_headers (takes a Hash of headers; returns an instance of includer's own class whose 13 | # headers have been updated with the argument headers) 14 | module Signable 15 | # composes a string suitable for private-key signing from the SIGNATURE_COMPONENTS keys of 16 | # attributes for signing, which are themselves taken from #attributes_for_signing and 17 | # the given argument more_attributes 18 | 19 | # the string to sign for V1 protocol will be (where LF is line feed character) 20 | # for requests: 21 | # string_to_sign = 22 | # http_verb + + 23 | # resource_url_path (no host, port or query string; first "/" is included) + + 24 | # request_body + + 25 | # app_uuid + + 26 | # current_seconds_since_epoch 27 | # 28 | # for responses: 29 | # string_to_sign = 30 | # status_code_string + + 31 | # response_body + + 32 | # app_uuid + + 33 | # current_seconds_since_epoch 34 | def string_to_sign_v1(more_attributes) 35 | attributes_for_signing = self.attributes_for_signing.merge(more_attributes) 36 | missing_attributes = self.class::SIGNATURE_COMPONENTS.select do |key| 37 | !attributes_for_signing.key?(key) || attributes_for_signing[key].nil? 38 | end 39 | missing_attributes.delete(:body) # body may be omitted 40 | if missing_attributes.any? 41 | raise(UnableToSignError, 42 | "Missing required attributes to sign: #{missing_attributes.inspect}\non object to sign: #{inspect}") 43 | end 44 | 45 | self.class::SIGNATURE_COMPONENTS.map { |k| attributes_for_signing[k].to_s }.join("\n") 46 | end 47 | 48 | # the string to sign for V2 protocol will be (where LF is line feed character) 49 | # for requests: 50 | # string_to_sign = 51 | # http_verb + + 52 | # resource_url_path (no host, port or query string; first "/" is included) + + 53 | # request_body_digest + + 54 | # app_uuid + + 55 | # current_seconds_since_epoch + + 56 | # encoded_query_params 57 | # 58 | # for responses: 59 | # string_to_sign = 60 | # status_code_string + + 61 | # response_body_digest + + 62 | # app_uuid + + 63 | # current_seconds_since_epoch 64 | def string_to_sign_v2(override_attrs) 65 | attrs_with_overrides = attributes_for_signing.merge(override_attrs) 66 | 67 | # memoization of body_digest to avoid hashing three times when we call 68 | # string_to_sign_v2 three times in client#signature_valid_v2! 69 | # note that if :body is nil we hash an empty string ('') 70 | attrs_with_overrides[:body_digest] ||= OpenSSL::Digest.hexdigest('SHA512', attrs_with_overrides[:body] || '') 71 | attrs_with_overrides[:encoded_query_params] = 72 | unescape_encode_query_string(attrs_with_overrides[:query_string] || '') 73 | attrs_with_overrides[:request_url] = normalize_path(attrs_with_overrides[:request_url]) 74 | 75 | missing_attributes = self.class::SIGNATURE_COMPONENTS_V2.reject do |key| 76 | attrs_with_overrides[key] 77 | end 78 | 79 | missing_attributes.delete(:body_digest) # body may be omitted 80 | missing_attributes.delete(:encoded_query_params) # query_string may be omitted 81 | if missing_attributes.any? 82 | raise(UnableToSignError, 83 | "Missing required attributes to sign: #{missing_attributes.inspect}\non object to sign: #{inspect}") 84 | end 85 | 86 | self.class::SIGNATURE_COMPONENTS_V2.map do |k| 87 | attrs_with_overrides[k].to_s.dup.force_encoding('UTF-8') 88 | end.join("\n") 89 | end 90 | 91 | # Addressable::URI.parse(path).normalize.to_s.squeeze('/') 92 | def normalize_path(path) 93 | return if path.nil? 94 | 95 | # Addressable::URI.normalize_path normalizes `.` and `..` in path 96 | # i.e. /./example => /example ; /example/.. => / 97 | # String#squeeze removes duplicated slahes i.e. /// => / 98 | # String#gsub normalizes percent encoding to uppercase i.e. %cf%80 => %CF%80 99 | Addressable::URI.normalize_path(path).squeeze('/') 100 | .gsub(/%[a-f0-9]{2}/, &:upcase) 101 | end 102 | 103 | # sorts query string parameters by codepoint, uri encodes keys and values, 104 | # and rejoins parameters into a query string 105 | def unescape_encode_query_string(q_string) 106 | q_string.split('&').map do |part| 107 | k, _eq, v = part.partition('=') 108 | [CGI.unescape(k), CGI.unescape(v)] 109 | end.sort.map do |k, v| # rubocop:disable Style/MultilineBlockChain 110 | "#{uri_escape(k)}=#{uri_escape(v)}" 111 | end.join('&') 112 | end 113 | 114 | # percent encodes special characters, preserving character encoding. 115 | # encodes space as '%20' 116 | # does not encode A-Z, a-z, 0-9, hyphen ( - ), underscore ( _ ), period ( . ), 117 | # or tilde ( ~ ) 118 | # NOTE the CGI.escape spec changed in 2.5 to not escape tildes. we gsub 119 | # tilde encoding back to tildes to account for older Rubies 120 | def uri_escape(string) 121 | CGI.escape(string).gsub(/\+|%7E/, '+' => '%20', '%7E' => '~') 122 | end 123 | 124 | def initialize(attributes_for_signing) 125 | @attributes_for_signing = attributes_for_signing 126 | end 127 | 128 | def attributes_for_signing 129 | @attributes_for_signing 130 | end 131 | end 132 | 133 | # methods for an incoming object which is expected to have a signature. 134 | # 135 | # includer must provide 136 | # - #mcc_authentication which returns that header's value 137 | # - #mcc_time 138 | # OR 139 | # - #x_mws_authentication which returns that header's value 140 | # - #x_mws_time 141 | module Signed 142 | # mauth_client will authenticate with the highest protocol version present and if authentication fails, 143 | # will fall back to lower protocol versions (if provided). 144 | # returns a hash with keys :token, :app_uuid, and :signature parsed from the MCC-Authentication header 145 | # if it is present and if not then the X-MWS-Authentication header if it is present. 146 | # Note MWSV2 protocol no longer allows more than one space between the token and app uuid. 147 | def signature_info 148 | @signature_info ||= build_signature_info(mcc_data || x_mws_data) 149 | end 150 | 151 | def fall_back_to_mws_signature_info 152 | @signature_info = build_signature_info(x_mws_data) 153 | end 154 | 155 | def signature_app_uuid 156 | signature_info[:app_uuid] 157 | end 158 | 159 | def signature_token 160 | signature_info[:token] 161 | end 162 | 163 | def signature 164 | signature_info[:signature] 165 | end 166 | 167 | def protocol_version 168 | if !mcc_authentication.to_s.strip.empty? 169 | 2 170 | elsif !x_mws_authentication.to_s.strip.empty? 171 | 1 172 | end 173 | end 174 | 175 | private 176 | 177 | def build_signature_info(match_data) 178 | match_data ? { token: match_data[1], app_uuid: match_data[2], signature: match_data[3] } : {} 179 | end 180 | 181 | def mcc_data 182 | mcc_authentication&.match( 183 | /\A(#{MAuth::Client::MWSV2_TOKEN}) ([^:]+):([^:]+)#{MAuth::Client::AUTH_HEADER_DELIMITER}\z/o 184 | ) 185 | end 186 | 187 | def x_mws_data 188 | x_mws_authentication&.match(/\A([^ ]+) *([^:]+):([^:]+)\z/) 189 | end 190 | end 191 | 192 | # virtual base class for signable requests 193 | class Request 194 | SIGNATURE_COMPONENTS = %i[verb request_url body app_uuid time].freeze 195 | SIGNATURE_COMPONENTS_V2 = 196 | %i[ 197 | verb 198 | request_url 199 | body_digest 200 | app_uuid 201 | time 202 | encoded_query_params 203 | ].freeze 204 | 205 | include Signable 206 | end 207 | 208 | # virtual base class for signable responses 209 | class Response 210 | SIGNATURE_COMPONENTS = %i[status_code body app_uuid time].freeze 211 | SIGNATURE_COMPONENTS_V2 = %i[status_code body_digest app_uuid time].freeze 212 | include Signable 213 | end 214 | end 215 | -------------------------------------------------------------------------------- /exe/mauth-client: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | $LOAD_PATH.unshift File.expand_path('../lib', File.dirname(__FILE__)) 5 | 6 | require 'faraday' 7 | require 'logger' 8 | require 'mauth/client' 9 | require 'mauth/faraday' 10 | require 'yaml' 11 | require 'term/ansicolor' 12 | 13 | # OPTION PARSER 14 | 15 | require 'optparse' 16 | 17 | # $options default values 18 | $options = { 19 | authenticate_response: true, 20 | verbose: true, 21 | color: nil, 22 | no_ssl_verify: false 23 | } 24 | 25 | additional_headers = [] 26 | opt_parser = OptionParser.new do |opts| 27 | opts.banner = 'Usage: mauth-client [options] [body]' 28 | 29 | opts.on('-v', '--[no-]verbose', 'Run verbosely - output is like curl -v (this is the default)') do |v| 30 | $options[:verbose] = v 31 | end 32 | opts.on('-q', 'Run quietly - only outputs the response body (same as --no-verbose)') do |v| 33 | $options[:verbose] = !v 34 | end 35 | opts.on('--[no-]authenticate', 'Authenticate the response received') do |v| 36 | $options[:authenticate_response] = v 37 | end 38 | opts.on('--[no-]color', 'Color the output (defaults to color if the output device is a TTY)') do |v| 39 | $options[:color] = v 40 | end 41 | opts.on('-t', '--content-type CONTENT-TYPE', 'Sets the Content-Type header of the request') do |v| 42 | $options[:content_type] = v 43 | end 44 | opts.on('-H', '--header LINE', 45 | "accepts a json string of additional headers to included. IE 'cache-expirey: 10, other: value") do |v| 46 | additional_headers << v 47 | end 48 | opts.on('--no-ssl-verify', 'Disables SSL verification - use cautiously!') do 49 | $options[:no_ssl_verify] = true 50 | end 51 | $options[:additional_headers] = additional_headers 52 | end 53 | opt_parser.parse! 54 | abort(opt_parser.help) unless (2..3).cover?(ARGV.size) 55 | 56 | # INSTANTIATE MAUTH CLIENT 57 | 58 | mauth_config = MAuth::ConfigEnv.load 59 | logger = Logger.new($stderr) 60 | mauth_client = MAuth::Client.new(mauth_config.merge('logger' => logger)) 61 | 62 | # OUTPUTTERS FOR FARADAY THAT SHOULD MOVE TO A LIB SOMEWHERE 63 | 64 | # outputs the response body to the given output device (defaulting to STDOUT) 65 | class FaradayOutputter < Faraday::Middleware 66 | def initialize(app, outdev = $stdout) 67 | @app = app 68 | @outdev = outdev 69 | end 70 | 71 | def call(request_env) 72 | @app.call(request_env).on_complete do |response_env| 73 | @outdev.puts(response_env[:body] || '') 74 | end 75 | end 76 | end 77 | 78 | # this is to approximate `curl -v`s output. but it's all faked, whereas curl gives you 79 | # the real text written and read for request and response. whatever, close enough. 80 | class FaradayCurlVOutputter < FaradayOutputter 81 | # defines a method with the given name, applying coloring defined by any additional arguments. 82 | # if $options[:color] is set, respects that; otherwise, applies color if the output device is a tty. 83 | def self.color(name, *color_args) 84 | define_method(name) do |arg| 85 | if color? 86 | color_args.inject(arg) do |result, color_arg| 87 | Term::ANSIColor.send(color_arg, result) 88 | end 89 | else 90 | arg 91 | end 92 | end 93 | end 94 | 95 | color :info, :intense_yellow 96 | color :info_body, :yellow 97 | color :protocol 98 | 99 | color :request, :intense_cyan 100 | color :request_verb, :bold 101 | color :request_header 102 | color :request_blankline, :intense_cyan, :bold 103 | 104 | color :response, :intense_green 105 | color :response_status, :bold, :green 106 | color :response_header 107 | color :response_blankline, :intense_green, :bold 108 | 109 | def call(request_env) # rubocop:disable Metrics/AbcSize 110 | @outdev.puts "#{info('*')} #{info_body("connect to #{request_env[:url].host} on port #{request_env[:url].port}")}" 111 | @outdev.puts "#{info('*')} #{info_body("getting our SSL on")}" if request_env[:url].scheme == 'https' 112 | @outdev.puts "#{request('>')} #{request_verb(request_env[:method].to_s.upcase)} #{request_env[:url].path}" \ 113 | "#{protocol('HTTP/1.1' || 'or something - TODO')}" 114 | request_env[:request_headers].each do |k, v| 115 | @outdev.puts "#{request('>')} #{request_header(k)}#{request(':')} #{v}" 116 | end 117 | @outdev.puts "#{request_blankline('>')} " 118 | request_body = color_body_by_content_type(request_env[:body], request_env[:request_headers]['Content-Type']) 119 | (request_body || '').split("\n", -1).each do |line| 120 | @outdev.puts "#{request('>')} #{line}" 121 | end 122 | @app.call(request_env).on_complete do |response_env| 123 | @outdev.puts "#{response('<')} #{protocol('HTTP/1.1' || 'or something - TODO')} " \ 124 | "#{response_status(response_env[:status].to_s)}" 125 | request_env[:response_headers].each do |k, v| 126 | @outdev.puts "#{response('<')} #{response_header(k)}#{response(':')} #{v}" 127 | end 128 | @outdev.puts "#{response_blankline('<')} " 129 | response_body = color_body_by_content_type(response_env[:body], response_env[:response_headers]['Content-Type']) 130 | (response_body || '').split("\n", -1).each do |line| 131 | @outdev.puts "#{response('<')} #{line}" 132 | end 133 | end 134 | end 135 | 136 | # whether to use color 137 | def color? 138 | $options[:color].nil? ? @outdev.tty? : $options[:color] 139 | end 140 | 141 | # a mapping for each registered CodeRay scanner to the Media Types which represent 142 | # that language. extremely incomplete! 143 | CODE_RAY_FOR_MEDIA_TYPES = { 144 | c: [], 145 | cpp: [], 146 | clojure: [], 147 | css: ['text/css', 'application/css-stylesheet'], 148 | delphi: [], 149 | diff: [], 150 | erb: [], 151 | groovy: [], 152 | haml: [], 153 | html: ['text/html'], 154 | java: [], 155 | java_script: ['application/javascript', 'text/javascript', 'application/x-javascript'], 156 | json: ['application/json', %r{\Aapplication/.*\+json\z}], 157 | php: [], 158 | python: ['text/x-python'], 159 | ruby: [], 160 | sql: [], 161 | xml: ['text/xml', 'application/xml', %r{\Aapplication/.*\+xml\z}], 162 | yaml: [] 163 | }.freeze 164 | 165 | # takes a body and a content type; returns the body, with coloring (ansi colors for terminals) 166 | # possibly added, if it's a recognized content type and #color? is true 167 | def color_body_by_content_type(body, content_type) 168 | return body unless body && color? 169 | 170 | # kinda hacky way to get the media_type. faraday should supply this ... 171 | require 'rack' 172 | media_type = ::Rack::Request.new({ 'CONTENT_TYPE' => content_type }).media_type 173 | coderay_scanner = CODE_RAY_FOR_MEDIA_TYPES.select { |_k, v| v.any?(media_type) }.keys.first 174 | return body unless coderay_scanner 175 | 176 | require 'coderay' 177 | if coderay_scanner == :json 178 | body = begin 179 | JSON.pretty_generate(JSON.parse(body)) 180 | rescue JSON::ParserError 181 | body 182 | end 183 | end 184 | CodeRay.scan(body, coderay_scanner).encode(:terminal) 185 | end 186 | end 187 | 188 | # CONFIGURE THE FARADAY CONNECTION 189 | faraday_options = {} 190 | if $options[:no_ssl_verify] 191 | faraday_options[:ssl] = { verify: false } 192 | end 193 | connection = Faraday.new(faraday_options) do |builder| 194 | builder.use MAuth::Faraday::MAuthClientUserAgent, 'MAuth-Client CLI' 195 | builder.use MAuth::Faraday::RequestSigner, mauth_client: mauth_client 196 | if $options[:authenticate_response] 197 | builder.use MAuth::Faraday::ResponseAuthenticator, mauth_client: mauth_client 198 | end 199 | builder.use $options[:verbose] ? FaradayCurlVOutputter : FaradayOutputter 200 | builder.adapter Faraday.default_adapter 201 | end 202 | 203 | httpmethod, url, body = *ARGV 204 | 205 | unless Faraday::Connection::METHODS.map { |m| m.to_s.downcase }.include?(httpmethod.downcase) 206 | abort "Unrecognized HTTP method given: #{httpmethod}\n\n" + opt_parser.help 207 | end 208 | 209 | headers = {} 210 | if $options[:content_type] 211 | headers['Content-Type'] = $options[:content_type] 212 | elsif body 213 | headers['Content-Type'] = 'application/json' 214 | # I'd rather not have a default content-type, but if none is set then the HTTP adapter sets this to 215 | # application/x-www-form-urlencoded anyway. application/json is a better default for our purposes. 216 | end 217 | 218 | $options[:additional_headers]&.each do |cur| 219 | raise 'Headers must be in the format of [key]:[value]' unless cur.include?(':') 220 | 221 | key, _throw_away, value = cur.partition(':') 222 | headers[key] = value 223 | end 224 | 225 | # OH LOOK IT'S FINALLY ACTUALLY CONNECTING TO SOMETHING 226 | 227 | begin 228 | connection.run_request(httpmethod.downcase.to_sym, url, body, headers) 229 | rescue MAuth::InauthenticError, MAuth::UnableToAuthenticateError, MAuth::MAuthNotPresent, MAuth::MissingV2Error => e 230 | if $options[:color].nil? ? $stderr.tty? : $options[:color] 231 | class_color = Term::ANSIColor.method(e.is_a?(MAuth::UnableToAuthenticateError) ? :intense_yellow : :intense_red) 232 | message_color = Term::ANSIColor.method(e.is_a?(MAuth::UnableToAuthenticateError) ? :yellow : :red) 233 | else 234 | class_color = proc { |s| s } 235 | message_color = proc { |s| s } 236 | end 237 | warn(class_color.call(e.class.to_s)) 238 | warn(message_color.call(e.message)) 239 | end 240 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [7.3.0](https://github.com/mdsol/mauth-client-ruby/compare/v7.2.0...v7.3.0) (2025-01-16) 4 | 5 | 6 | ### Features 7 | 8 | * Support ruby 3.4 ([97f0f32](https://github.com/mdsol/mauth-client-ruby/commit/97f0f3211d5da8c92fc0ab6bbf44c6ad6929c807)) 9 | 10 | ## [7.2.0](https://github.com/mdsol/mauth-client-ruby/compare/v7.1.0...v7.2.0) (2024-04-25) 11 | 12 | 13 | ### Features 14 | 15 | * Support Ruby 3.3 ([245bb06](https://github.com/mdsol/mauth-client-ruby/commit/245bb06d8abb86bd6a4b557b84bc9d0898254a95)) 16 | 17 | ## 7.1.0 18 | - Add MAuth::PrivateKeyHelper.load method to process RSA private keys. 19 | - Update Faraday configuration in SecurityTokenCacher: 20 | - Add the `MAUTH_USE_RAILS_CACHE` environment variable to make `Rails.cache` usable to cache public keys. 21 | - Shorten timeout for connection, add retries, and use persistent HTTP connections. 22 | - Drop support for Faraday < 1.9. 23 | 24 | ## 7.0.0 25 | - Remove dice_bag and set configuration through environment variables directly. 26 | - Rename the `V2_ONLY_SIGN_REQUESTS`, `V2_ONLY_AUTHENTICATE`, `DISABLE_FALLBACK_TO_V1_ON_V2_FAILURE` and `V1_ONLY_SIGN_REQUESTS` environment variables. 27 | - Remove the remote authenticator. 28 | - Support Ruby 3.2. 29 | 30 | See [UPGRADE_GUIDE.md](UPGRADE_GUIDE.md#upgrading-to-700) for migration. 31 | 32 | ## 6.4.3 33 | - Force Rack > 2.2.3 to resolve [CVE-2022-30123](https://github.com/advisories/GHSA-wq4h-7r42-5hrr). 34 | 35 | ## 6.4.2 36 | - Add MAuth::ServerHelper module with convenience methods for servers to access requester app uuid. 37 | 38 | ## 6.4.1 39 | - Fix MAuth::Rack::Response to not raise FrozenError. 40 | 41 | ## 6.4.0 42 | - Support Ruby 3.1. 43 | - Drop support for Ruby < 2.6.0. 44 | - Allow Faraday 2.x. 45 | 46 | ## 6.3.0 47 | - Support Ruby 3.0. 48 | - Drop support for Ruby < 2.5.0. 49 | 50 | ## 6.2.1 51 | - Fix SecurityTokenCacher to not cache tokens forever. 52 | 53 | ## 6.2.0 54 | - Drop legacy security token expiry in favor of honoring server cache headers via Faraday HTTP Cache Middleware. 55 | 56 | ## 6.1.1 57 | - Replace `URI.escape` with `CGI.escape` in SecurityTokenCacher to suppress "URI.escape is obsolete" warning. 58 | 59 | ## 6.1.0 60 | - Allow Faraday 1.x. 61 | 62 | ## 6.0.0 63 | - Added parsing code to test with mauth-protocol-test-suite. 64 | - Added unescape step in query_string encoding in order to remove 'double encoding'. 65 | - Added normalization of paths. 66 | - Added flag to sign only with V1. 67 | - Changed V2 to V1 fallback to be configurable. 68 | - Fixed bug in sorting query parameters. 69 | 70 | ## 5.1.0 71 | - Fall back to V1 when V2 authentication fails. 72 | 73 | ## 5.0.2 74 | - Fix to not raise FrozenError when string to sign contains frozen value. 75 | 76 | ## 5.0.1 77 | - Update euresource escaping of query string. 78 | 79 | ## 5.0.0 80 | - Add support for MWSV2 protocol. 81 | - Change request signing to sign with both V1 and V2 protocols by default. 82 | - Update log message for authentication request to include protocol version used. 83 | - Added `benchmark` rake task to benchmark request signing and authentication. 84 | 85 | ## 4.1.1 86 | - Use warning level instead of error level for logs about missing mauth header. 87 | 88 | ## 4.1.0 89 | - Drop support for Ruby < 2.3.0 90 | - Update development dependencies 91 | 92 | ## 4.0.4 93 | - Restore original behavior in the proxy of forwarding of headers that begin with HTTP_ (except for HTTP_HOST) but removing the HTTP_. 94 | 95 | ## 4.0.3 96 | - Updated signature to decode number sign (#) in requests 97 | 98 | ## 4.0.2 99 | - Store the config data to not load the config file multiple times 100 | 101 | ## 4.0.1 102 | - Open source and publish this gem on rubygems.org, no functionality changes 103 | 104 | ## 4.0.0 105 | - *yanked* 106 | 107 | ## 3.1.4 108 | - Use String#bytesize method instead of Rack::Utils' one, which was removed in Rack 2.0 109 | 110 | ## 3.1.3 111 | - Increased the default timeout when fetching keys from MAuth from 1 second to 10 seconds 112 | - Properly honor faraday_options: timeout in mauth.yml for faraday < 0.9 113 | 114 | ## 3.1.2 115 | - Fixed bug in Faraday call, not to raise exception when adding authenticate information to response. 116 | 117 | ## 3.1.1 118 | - Properly require version file. Solves exception with the Faraday middleware. 119 | 120 | ## 3.1.0 121 | - Updated `mauth.rb.dice` template to use `MAuth::Client.default_config` method and store the config in `MAUTH_CONF` constant 122 | 123 | ## 3.0.2 124 | - Always pass a private key to the `ensure_is_private_key` method 125 | 126 | ## 3.0.1 127 | - Use `ensure_is_private_key` in the `mauth_key` template 128 | 129 | ## 3.0.0 130 | - Drop support for ruby 1.x 131 | 132 | ## 2.9.0 133 | - Add a dice template for mauth initializer 134 | 135 | ## 2-8-stable 136 | - Added an ssl_certs_path option to support JRuby applications 137 | - Updated dice templates to ensure `rake config` raises an error in production env if required variables are missing. 138 | 139 | ## 2.7.2 140 | - Added logging of mauth app_uuid of requester and requestee on each request 141 | 142 | ## 2.7.0 143 | - Ability to pass custom headers into mauth-client and mauth-proxy 144 | - Upgraded to use newest version of Faraday Middleware 145 | - Faraday_options now only get merged to the request (previously got merged into everything) 146 | - Syntax highlighting in hale+json output 147 | 148 | ## 2.6.4 149 | - Less restrictive rack versioning to allow for more consumers. 150 | - Allow verification even if intermediate web servers unescape URLs. 151 | 152 | ## 2.6.3 153 | - Fixed bug where nil Rails.logger prevented a logger from being built. 154 | 155 | ## 2.6.2 156 | - Added templates for dice_bag, now rake config:generate_all will create mauth config files when you include this gem. 157 | 158 | ## 2.6.1 159 | - Imported documentation from Medinet into the project's doc directory 160 | - Add Shamus 161 | 162 | ## 2.6.0 163 | - CLI option --no-ssl-verify disables SSL verification 164 | - Syntax highlighting with CodeRay colorizes request and response bodies of recognized media types 165 | - MAuth::Proxy class now lives in lib, in mauth/proxy, and may be used as a rack application 166 | - mauth-proxy executable recognizes --no-authenticate option for responses 167 | - MAuth::Proxy bugfix usage of REQUEST_URI; use Rack::Request#fullpath instead 168 | 169 | ## 2.5.0 170 | - MAuth::Rack::RequestAuthenticator middleware responds with json (instead of text/plain) for inauthentic requests and requests which it is unable to authenticate 171 | - Added MAuth::Client.default_config method 172 | - Added mauth-proxy executable 173 | - Faraday middlewares are registered with Faraday 174 | - Rack middleware correctly handles Content-Length with HEAD requests 175 | - MAuth::Client raises MAuth::Client::ConfigurationError instead of ArgumentError or RuntimeError as appropriate 176 | 177 | ## 2.4.0 178 | - Colorized output from the mauth-client CLI 179 | - Add --content-type option to CLI 180 | - CLI rescues and prints MAuth errors instead of them bubbling up to the interpreter 181 | - Improved method documentation 182 | - Fix default null logger on windows where /dev/null is not available 183 | - Improve error logging 184 | 185 | ## 2.3.0 186 | - When authentication headers are missing, the previous message ("No x-mws-time present") is replaced by the somewhat more informative "Authentication Failed. No mAuth signature present; X-MWS-Authentication header is blank." 187 | - More informative help messages from mauth-client CLI 188 | - CLI sets a user-agent 189 | - Handling timeout errors is fixed (previously only handled connection errors) 190 | - Middleware MAuth::Rack::RequestAuthenticationFaker for testing 191 | - More and better specs 192 | 193 | ## 2.2.0 194 | - Fixes an issue where requests which have a body and are not PUT or POST were not being correctly signed in rack middleware 195 | - Improves the CLI, adding command-line options --[no-]authenticate to decide whether to authenticate responses, and --[no-]verbose to decide whether to dump the entire request and response, or just the response body. and --help to 196 | Remind you. 197 | - Fixes mauth-client CLI being registered as an executable in the gemspec - now it should be possible to just `bundle exec mauth-client` if you have the gem bundle installed (or just `mauth-client` if you have it installed as a regular gem, but that's less straightforward) 198 | - New middleware MAuth::Rack::RequestAuthenticatorNoAppStatus - same as MAuth::Rack::RequestAuthenticator, but does not authenticate /app_status. this will be the most commonly used case, so made it its own middleware. 199 | - Middleware responds to HEAD requests correctly in error conditions, not including a response body 200 | - Drops backports dependency (Ben has found some issues with this gem, and it was easier to drop the depedency entirely than figure out whether these issues affected mauth-client and if it could be fixed) 201 | - Fix issue with remote authentication against the currently-deployed mauth service with a request signed by a nonexistent app_uuid 202 | 203 | ## 2.1.1 204 | - Fix an issue in a case where the rack.input is not rewound before mauth-client attempts to read it 205 | 206 | ## 2.1.0 207 | - MAuth::Client handles the :private_key_file, so you can remove from your application the bit that does that - this bit can be deleted: 208 | ``` 209 | if mauth_conf['private_key_file'] 210 | mauth_conf['private_key'] = File.read(mauth_conf['private_key_file']) 211 | end 212 | ``` 213 | 214 | - Autoloads are in place so that once you require 'mauth/client', you should not need to require mauth/rack, mauth/faraday, or mauth/request_and_response. 215 | 216 | ## 2.0.0 217 | - Rewrite combining the mauth_signer and rack-mauth gems 218 | -------------------------------------------------------------------------------- /spec/signable_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'mauth/request_and_response' 5 | require 'mauth/client' 6 | 7 | describe MAuth::Signable do 8 | let(:more_attrs) { { time: Time.now, app_uuid: 'signer' } } 9 | let(:resp_attrs) { { status_code: 200, body: '{"k": "v"}' }.merge(more_attrs) } 10 | let(:req_attrs) do 11 | { verb: 'PUT', request_url: '/', body: '{}', query_string: 'k=v' } 12 | .merge(more_attrs) 13 | end 14 | let(:frozen_req_attrs) { req_attrs.transform_values { |v| v.is_a?(String) ? v.freeze : v } } 15 | let(:dummy_cls) do 16 | dummy = Class.new do 17 | include MAuth::Signable 18 | end 19 | dummy.send(:remove_const, const_name) if dummy.const_defined?(const_name) 20 | dummy.const_set(const_name, sig_components) 21 | dummy 22 | end 23 | let(:dummy_inst) { Class.new { include MAuth::Signable }.new({}) } 24 | 25 | describe 'string_to_sign_v1' do 26 | let(:const_name) { 'SIGNATURE_COMPONENTS' } 27 | 28 | context 'requests' do 29 | let(:sig_components) { %i[verb request_url body app_uuid time] } 30 | 31 | %i[verb request_url app_uuid time].each do |component| 32 | it "raises when the signature component `#{component}` is missing" do 33 | req_attrs.delete(component) 34 | dummy_inst = dummy_cls.new(req_attrs) 35 | expect { dummy_inst.string_to_sign_v1({}) } 36 | .to raise_error(MAuth::UnableToSignError) 37 | end 38 | end 39 | 40 | it 'does not raise when `body` is missing' do 41 | req_attrs.delete(:body) 42 | dummy_inst = dummy_cls.new(req_attrs) 43 | expect { dummy_inst.string_to_sign_v1({}) }.not_to raise_error 44 | end 45 | end 46 | 47 | context 'responses' do 48 | let(:sig_components) { %i[status_code body app_uuid time] } 49 | 50 | %i[status_code app_uuid time].each do |component| 51 | it "raises when the signature component `#{component}` is missing" do 52 | resp_attrs.delete(component) 53 | dummy_inst = dummy_cls.new(resp_attrs) 54 | expect { dummy_inst.string_to_sign_v1({}) } 55 | .to raise_error(MAuth::UnableToSignError) 56 | end 57 | end 58 | 59 | it 'does not raise when `body` is missing' do 60 | resp_attrs.delete(:body) 61 | dummy_inst = dummy_cls.new(resp_attrs) 62 | expect { dummy_inst.string_to_sign_v1({}) }.not_to raise_error 63 | end 64 | end 65 | end 66 | 67 | describe 'string_to_sign_v2' do 68 | let(:const_name) { 'SIGNATURE_COMPONENTS_V2' } 69 | 70 | context 'requests' do 71 | let(:sig_components) do 72 | %i[verb request_url body_digest app_uuid time encoded_query_params] 73 | end 74 | 75 | %i[verb request_url app_uuid time].each do |component| 76 | it "raises when the signature component `#{component}` is missing" do 77 | req_attrs.delete(component) 78 | dummy_req = dummy_cls.new(req_attrs) 79 | expect { dummy_req.string_to_sign_v2({}) } 80 | .to raise_error(MAuth::UnableToSignError) 81 | end 82 | end 83 | 84 | %i[body_digest encoded_query_params].each do |component| 85 | it "does not raise when the signature component `#{component}` is missing" do 86 | req_attrs.delete(component) 87 | dummy_req = dummy_cls.new(req_attrs) 88 | expect { dummy_req.string_to_sign_v2({}) }.not_to raise_error 89 | end 90 | end 91 | 92 | it 'hashes the request body with SHA512' do 93 | expect(OpenSSL::Digest).to receive(:hexdigest).with('SHA512', req_attrs[:body]).once 94 | dummy_req = dummy_cls.new(req_attrs) 95 | dummy_req.string_to_sign_v2({}) 96 | end 97 | 98 | it 'enforces UTF-8 encoding for all components of the string to sign' do 99 | dummy_req = dummy_cls.new(req_attrs) 100 | str = dummy_req.string_to_sign_v2({}) 101 | 102 | str.split("\n\r").each do |component| 103 | expect(component.encoding.to_s).to eq('UTF-8') 104 | end 105 | end 106 | 107 | it 'does not raise when all string components of the string to sign are frozen' do 108 | dummy_req = dummy_cls.new(frozen_req_attrs) 109 | expect { dummy_req.string_to_sign_v2({}) }.not_to raise_error 110 | end 111 | 112 | # we have this spec because Faraday and Rack handle empty request bodies 113 | # differently. 114 | # our Rack::Request class reads the body of a bodiless-request as an empty string 115 | # our Faraday::Request class reads the body of a bodiless-request as nil 116 | it 'treats requests where the body is nil and the body is an empty request the same' do 117 | nil_body_attrs = req_attrs.merge(body: nil) 118 | empty_body_attrs = req_attrs.merge(body: '') 119 | 120 | nil_req = dummy_cls.new(nil_body_attrs) 121 | empy_req = dummy_cls.new(empty_body_attrs) 122 | 123 | expect(nil_req.string_to_sign_v2({})).to eq(empy_req.string_to_sign_v2({})) 124 | end 125 | end 126 | 127 | context 'responses' do 128 | let(:sig_components) { %i[status_code body_digest app_uuid time] } 129 | 130 | %i[status_code app_uuid time].each do |component| 131 | it "raises when the signature component `#{component}` is missing" do 132 | resp_attrs.delete(component) 133 | dummy_resp = dummy_cls.new(resp_attrs) 134 | expect { dummy_resp.string_to_sign_v2({}) } 135 | .to raise_error(MAuth::UnableToSignError) 136 | end 137 | end 138 | 139 | it 'does not raise when `body_digest` is missing' do 140 | resp_attrs.delete(:body_digest) 141 | dummy_resp = dummy_cls.new(resp_attrs) 142 | expect { dummy_resp.string_to_sign_v2({}) }.not_to raise_error 143 | end 144 | 145 | it 'hashes the response body with SHA512' do 146 | expect(OpenSSL::Digest).to receive(:hexdigest).with('SHA512', resp_attrs[:body]).once 147 | dummy_req = dummy_cls.new(resp_attrs) 148 | dummy_req.string_to_sign_v2({}) 149 | end 150 | 151 | it 'enforces UTF-8 encoding for all components of the string to sign' do 152 | dummy_req = dummy_cls.new(resp_attrs) 153 | str = dummy_req.string_to_sign_v2({}) 154 | 155 | str.split("\n\r").each do |component| 156 | expect(component.encoding.to_s).to eq('UTF-8') 157 | end 158 | end 159 | end 160 | end 161 | 162 | describe 'unescape_encode_query_string' do 163 | shared_examples_for 'unescape_encode_query_string' do |desc, qs, expected| 164 | it "unescape_encode_query_string: #{desc}" do 165 | expect(dummy_inst.unescape_encode_query_string(qs)).to eq(expected) 166 | end 167 | end 168 | 169 | include_examples 'unescape_encode_query_string', 170 | 'uri encodes special characters in keys and values of the parameters', 171 | "key=-_.~!@#$%^*(){}|:\"'`<>?", 'key=-_.~%21%40%23%24%25%5E%2A%28%29%7B%7D%7C%3A%22%27%60%3C%3E%3F' 172 | 173 | include_examples 'unescape_encode_query_string', 'sorts query parameters by code point in ascending order', 174 | '∞=v&キ=v&0=v&a=v', '0=v&a=v&%E2%88%9E=v&%E3%82%AD=v' 175 | 176 | include_examples 'unescape_encode_query_string', 'sorts query parameters by value if keys are the same', 177 | 'a=b&a=c&a=a', 'a=a&a=b&a=c' 178 | 179 | include_examples 'unescape_encode_query_string', 'sorts query with the same base string correctly', 180 | 'key2=value2&key=value', 'key=value&key2=value2' 181 | 182 | include_examples 'unescape_encode_query_string', 'properly handles query strings with empty values', 183 | 'k=&k=v', 'k=&k=v' 184 | 185 | include_examples 'unescape_encode_query_string', 'properly handles empty strings', '', '' 186 | 187 | include_examples 'unescape_encode_query_string', 188 | 'unescapes special characters in the query string before encoding them', 189 | 'key=-_.%21%40%23%24%25%5E%2A%28%29%20%7B%7D%7C%3A%22%27%60%3C%3E%3F', 190 | 'key=-_.%21%40%23%24%25%5E%2A%28%29%20%7B%7D%7C%3A%22%27%60%3C%3E%3F' 191 | 192 | include_examples 'unescape_encode_query_string', 'unescapes "%7E" to "~"', 'k=%7E', 'k=~' 193 | 194 | include_examples 'unescape_encode_query_string', 'unescapes "+" to " "', 'k=+', 'k=%20' 195 | 196 | include_examples 'unescape_encode_query_string', 'sorts after unescaping', 197 | 'k=%7E&k=~&k=%40&k=a', 'k=%40&k=a&k=~&k=~' 198 | end 199 | 200 | describe 'uri_escape' do 201 | it 'uri encodes special characters' do 202 | str = "!@#$%^*()+{}|:\"'`<>?" 203 | expected = '%21%40%23%24%25%5E%2A%28%29%2B%7B%7D%7C%3A%22%27%60%3C%3E%3F' 204 | expect(dummy_inst.uri_escape(str)).to eq(expected) 205 | end 206 | 207 | %w[~ _ . - a A 0].each do |char| 208 | it "does not uri encode `#{char}`" do 209 | expect(dummy_inst.uri_escape(char)).to eq(char) 210 | end 211 | end 212 | 213 | it 'encodes space as %20' do 214 | expect(dummy_inst.uri_escape(' ')).to eq('%20') 215 | end 216 | end 217 | 218 | describe 'normalize_path' do 219 | shared_examples_for 'normalize_path' do |desc, path, expected| 220 | it "normalize_path: #{desc}" do 221 | expect(dummy_inst.normalize_path(path)).to eq(expected) 222 | end 223 | end 224 | 225 | include_examples 'normalize_path', 'self (".") in the path', '/./example/./.', '/example/' 226 | include_examples 'normalize_path', 'parent ("..") in path', '/example/sample/..', '/example/' 227 | include_examples 'normalize_path', 'parent ("..") that points to non-existent parent', 228 | '/example/sample/../../../..', '/' 229 | include_examples 'normalize_path', 'case of percent encoded characters', '/%2b', '/%2B' 230 | include_examples 'normalize_path', 'multiple adjacent slashes to a single slash', 231 | '//example///sample', '/example/sample' 232 | include_examples 'normalize_path', 'preserves trailing slashes', '/example/', '/example/' 233 | end 234 | end 235 | -------------------------------------------------------------------------------- /lib/mauth/client/authenticator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'mauth/client/security_token_cacher' 4 | require 'mauth/client/signer' 5 | require 'openssl' 6 | 7 | # methods to verify the authenticity of signed requests and responses 8 | 9 | module MAuth 10 | class Client 11 | module Authenticator 12 | ALLOWED_DRIFT_SECONDS = 300 13 | 14 | # takes an incoming request or response object, and returns whether 15 | # the object is authentic according to its signature. 16 | def authentic?(object) 17 | log_authentication_request(object) 18 | begin 19 | authenticate!(object) 20 | true 21 | rescue InauthenticError, MAuthNotPresent, MissingV2Error 22 | false 23 | end 24 | end 25 | 26 | # raises InauthenticError unless the given object is authentic. Will only 27 | # authenticate with v2 if the environment variable V2_ONLY_AUTHENTICATE 28 | # is set. Otherwise will fall back to v1 when v2 authentication fails 29 | def authenticate!(object) 30 | case object.protocol_version 31 | when 2 32 | begin 33 | authenticate_v2!(object) 34 | rescue InauthenticError => e 35 | raise e if v2_only_authenticate? 36 | raise e if disable_fallback_to_v1_on_v2_failure? 37 | 38 | object.fall_back_to_mws_signature_info 39 | raise e unless object.signature 40 | 41 | log_authentication_request(object) 42 | authenticate_v1!(object) 43 | logger.warn('Completed successful authentication attempt after fallback to v1') 44 | end 45 | when 1 46 | if v2_only_authenticate? 47 | # If v2 is required but not present and v1 is present we raise MissingV2Error 48 | msg = 'This service requires mAuth v2 mcc-authentication header but only v1 x-mws-authentication is present' 49 | logger.error(msg) 50 | raise MissingV2Error, msg 51 | end 52 | 53 | authenticate_v1!(object) 54 | else 55 | sub_str = v2_only_authenticate? ? '' : 'X-MWS-Authentication header is blank, ' 56 | msg = "Authentication Failed. No mAuth signature present; #{sub_str}MCC-Authentication header is blank." 57 | logger.warn("mAuth signature not present on #{object.class}. Exception: #{msg}") 58 | raise MAuthNotPresent, msg 59 | end 60 | end 61 | 62 | private 63 | 64 | # NOTE: This log is likely consumed downstream and the contents SHOULD NOT 65 | # be changed without a thorough review of downstream consumers. 66 | def log_authentication_request(object) 67 | object_app_uuid = object.signature_app_uuid || '[none provided]' 68 | object_token = object.signature_token || '[none provided]' 69 | logger.info( 70 | 'Mauth-client attempting to authenticate request from app with mauth ' \ 71 | "app uuid #{object_app_uuid} to app with mauth app uuid #{client_app_uuid} " \ 72 | "using version #{object_token}." 73 | ) 74 | end 75 | 76 | def log_inauthentic(object, message) 77 | logger.error("mAuth signature authentication failed for #{object.class}. Exception: #{message}") 78 | end 79 | 80 | def time_within_valid_range!(object, time_signed, now = Time.now) 81 | return if (-ALLOWED_DRIFT_SECONDS..ALLOWED_DRIFT_SECONDS).cover?(now.to_i - time_signed) 82 | 83 | msg = "Time verification failed. #{time_signed} not within #{ALLOWED_DRIFT_SECONDS} of #{now}" 84 | log_inauthentic(object, msg) 85 | raise InauthenticError, msg 86 | end 87 | 88 | # V1 helpers 89 | def authenticate_v1!(object) 90 | time_valid_v1!(object) 91 | token_valid_v1!(object) 92 | signature_valid_v1!(object) 93 | end 94 | 95 | def time_valid_v1!(object) 96 | if object.x_mws_time.nil? 97 | msg = 'Time verification failed. No x-mws-time present.' 98 | log_inauthentic(object, msg) 99 | raise InauthenticError, msg 100 | end 101 | time_within_valid_range!(object, object.x_mws_time.to_i) 102 | end 103 | 104 | def token_valid_v1!(object) 105 | return if object.signature_token == MWS_TOKEN 106 | 107 | msg = "Token verification failed. Expected #{MWS_TOKEN}; token was #{object.signature_token}" 108 | log_inauthentic(object, msg) 109 | raise InauthenticError, msg 110 | end 111 | 112 | # V2 helpers 113 | def authenticate_v2!(object) 114 | time_valid_v2!(object) 115 | token_valid_v2!(object) 116 | signature_valid_v2!(object) 117 | end 118 | 119 | def time_valid_v2!(object) 120 | if object.mcc_time.nil? 121 | msg = 'Time verification failed. No MCC-Time present.' 122 | log_inauthentic(object, msg) 123 | raise InauthenticError, msg 124 | end 125 | time_within_valid_range!(object, object.mcc_time.to_i) 126 | end 127 | 128 | def token_valid_v2!(object) 129 | return if object.signature_token == MWSV2_TOKEN 130 | 131 | msg = "Token verification failed. Expected #{MWSV2_TOKEN}; token was #{object.signature_token}" 132 | log_inauthentic(object, msg) 133 | raise InauthenticError, msg 134 | end 135 | 136 | def signature_valid_v1!(object) 137 | # We are in an unfortunate situation in which Euresource is percent-encoding parts of paths, but not 138 | # all of them. In particular, Euresource is percent-encoding all special characters save for '/'. 139 | # Also, unfortunately, Nginx unencodes URIs before sending them off to served applications, though 140 | # other web servers (particularly those we typically use for local testing) do not. The various forms 141 | # of the expected string to sign are meant to cover the main cases. 142 | # TODO: Revisit and simplify this unfortunate situation. 143 | 144 | original_request_uri = object.attributes_for_signing[:request_url] 145 | 146 | # craft an expected string-to-sign without doing any percent-encoding 147 | expected_no_reencoding = object.string_to_sign_v1(time: object.x_mws_time, app_uuid: object.signature_app_uuid) 148 | 149 | # do a simple percent reencoding variant of the path 150 | object.attributes_for_signing[:request_url] = CGI.escape(original_request_uri.to_s) 151 | expected_for_percent_reencoding = object.string_to_sign_v1(time: object.x_mws_time, 152 | app_uuid: object.signature_app_uuid) 153 | 154 | # do a moderately complex Euresource-style reencoding of the path 155 | object.attributes_for_signing[:request_url] = euresource_escape(original_request_uri.to_s) 156 | expected_euresource_style_reencoding = object.string_to_sign_v1(time: object.x_mws_time, 157 | app_uuid: object.signature_app_uuid) 158 | 159 | # reset the object original request_uri, just in case we need it again 160 | object.attributes_for_signing[:request_url] = original_request_uri 161 | 162 | begin 163 | pubkey = OpenSSL::PKey::RSA.new(retrieve_public_key(object.signature_app_uuid)) 164 | actual = pubkey.public_decrypt(Base64.decode64(object.signature)) 165 | rescue OpenSSL::PKey::PKeyError => e 166 | msg = "Public key decryption of signature failed! #{e.class}: #{e.message}" 167 | log_inauthentic(object, msg) 168 | raise InauthenticError, msg 169 | end 170 | 171 | unless verify_signature_v1!(actual, expected_no_reencoding) || 172 | verify_signature_v1!(actual, expected_euresource_style_reencoding) || 173 | verify_signature_v1!(actual, expected_for_percent_reencoding) 174 | msg = "Signature verification failed for #{object.class}" 175 | log_inauthentic(object, msg) 176 | raise InauthenticError, msg 177 | end 178 | end 179 | 180 | def verify_signature_v1!(actual, expected_str_to_sign) 181 | actual == OpenSSL::Digest::SHA512.hexdigest(expected_str_to_sign) 182 | end 183 | 184 | def signature_valid_v2!(object) 185 | # We are in an unfortunate situation in which Euresource is percent-encoding parts of paths, but not 186 | # all of them. In particular, Euresource is percent-encoding all special characters save for '/'. 187 | # Also, unfortunately, Nginx unencodes URIs before sending them off to served applications, though 188 | # other web servers (particularly those we typically use for local testing) do not. The various forms 189 | # of the expected string to sign are meant to cover the main cases. 190 | # TODO: Revisit and simplify this unfortunate situation. 191 | 192 | original_request_uri = object.attributes_for_signing[:request_url] 193 | original_query_string = object.attributes_for_signing[:query_string] 194 | 195 | # craft an expected string-to-sign without doing any percent-encoding 196 | expected_no_reencoding = object.string_to_sign_v2( 197 | time: object.mcc_time, 198 | app_uuid: object.signature_app_uuid 199 | ) 200 | 201 | # do a simple percent reencoding variant of the path 202 | expected_for_percent_reencoding = object.string_to_sign_v2( 203 | time: object.mcc_time, 204 | app_uuid: object.signature_app_uuid, 205 | request_url: CGI.escape(original_request_uri.to_s), 206 | query_string: CGI.escape(original_query_string.to_s) 207 | ) 208 | 209 | # do a moderately complex Euresource-style reencoding of the path 210 | expected_euresource_style_reencoding = object.string_to_sign_v2( 211 | time: object.mcc_time, 212 | app_uuid: object.signature_app_uuid, 213 | request_url: euresource_escape(original_request_uri.to_s), 214 | query_string: euresource_query_escape(original_query_string.to_s) 215 | ) 216 | 217 | pubkey = OpenSSL::PKey::RSA.new(retrieve_public_key(object.signature_app_uuid)) 218 | actual = Base64.decode64(object.signature) 219 | 220 | unless verify_signature_v2!(object, actual, pubkey, expected_no_reencoding) || 221 | verify_signature_v2!(object, actual, pubkey, expected_euresource_style_reencoding) || 222 | verify_signature_v2!(object, actual, pubkey, expected_for_percent_reencoding) 223 | msg = "Signature inauthentic for #{object.class}" 224 | log_inauthentic(object, msg) 225 | raise InauthenticError, msg 226 | end 227 | end 228 | 229 | def verify_signature_v2!(object, actual, pubkey, expected_str_to_sign) 230 | pubkey.verify( 231 | MAuth::Client::SIGNING_DIGEST, 232 | actual, 233 | expected_str_to_sign 234 | ) 235 | rescue OpenSSL::PKey::PKeyError => e 236 | msg = "RSA verification of signature failed! #{e.class}: #{e.message}" 237 | log_inauthentic(object, msg) 238 | raise InauthenticError, msg 239 | end 240 | 241 | # NOTE: RFC 3986 (https://www.ietf.org/rfc/rfc3986.txt) reserves the forward slash "/" 242 | # and number sign "#" as component delimiters. Since these are valid URI components, 243 | # they are decoded back into characters here to avoid signature invalidation 244 | def euresource_escape(str) 245 | CGI.escape(str).gsub(/%2F|%23/, '%2F' => '/', '%23' => '#') 246 | end 247 | 248 | # Euresource encodes keys and values of query params but does not encode the '=' 249 | # that separates keys and values and the '&' that separate k/v pairs 250 | # Euresource currently adds query parameters via the following method: 251 | # https://www.rubydoc.info/gems/addressable/2.3.4/Addressable/URI#query_values=-instance_method 252 | def euresource_query_escape(str) 253 | CGI.escape(str).gsub(/%3D|%26/, '%3D' => '=', '%26' => '&') 254 | end 255 | 256 | def retrieve_public_key(app_uuid) 257 | retrieve_security_token(app_uuid)['security_token']['public_key_str'] 258 | end 259 | 260 | def retrieve_security_token(app_uuid) 261 | security_token_cacher.get(app_uuid) 262 | end 263 | 264 | def security_token_cacher 265 | @security_token_cacher ||= SecurityTokenCacher.new(self) 266 | end 267 | end 268 | end 269 | end 270 | -------------------------------------------------------------------------------- /spec/middleware_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'faraday' 5 | require 'mauth/rack' 6 | require 'mauth/fake/rack' 7 | require 'mauth/faraday' 8 | 9 | shared_examples MAuth::Middleware do 10 | it 'uses a given mauth_client if given' do 11 | mauth_client = double 12 | expect(mauth_client).to eq(described_class.new(double('app'), mauth_client: mauth_client).mauth_client) 13 | expect(mauth_client).to eq(described_class.new(double('app'), 'mauth_client' => mauth_client).mauth_client) 14 | end 15 | 16 | it 'builds a mauth client if not given a mauth_client' do 17 | mauth_config = { mauth_baseurl: 'http://mauth', mauth_api_version: 'v1' } 18 | middleware_instance = described_class.new(double('app'), mauth_config) 19 | expect(mauth_config[:mauth_baseurl]).to eq(middleware_instance.mauth_client.mauth_baseurl) 20 | expect(mauth_config[:mauth_api_version]).to eq(middleware_instance.mauth_client.mauth_api_version) 21 | end 22 | end 23 | 24 | describe MAuth::Rack do 25 | let(:res) { [200, {}, ['hello world']] } 26 | let(:rack_app) { proc { |_env| res } } 27 | let(:v2_only_authenticate) { false } 28 | let(:mw) { described_class.new(rack_app, v2_only_authenticate: v2_only_authenticate) } 29 | 30 | describe MAuth::Rack::RequestAuthenticator do 31 | include_examples MAuth::Middleware 32 | 33 | it 'calls the app without authentication if should_authenticate check indicates not to' do 34 | mw_auth_false = described_class.new(rack_app, should_authenticate_check: proc { false }) 35 | env = { 'HTTP_X_MWS_AUTHENTICATION' => 'MWS foo:bar' } 36 | expect(mw_auth_false.mauth_client).not_to receive(:authentic?) 37 | expect(rack_app).to receive(:call).with(env).and_return(res) 38 | status, _headers, body = mw_auth_false.call(env) 39 | expect(200).to eq(status) 40 | expect(['hello world']).to eq(body) 41 | end 42 | 43 | it 'authenticates if should_authenticate_check is omitted or indicates to' do 44 | [nil, proc { |_env| true }].each do |should_authenticate_check| 45 | mw_w_flag = described_class.new(rack_app, should_authenticate_check: should_authenticate_check) 46 | env = { 'HTTP_X_MWS_AUTHENTICATION' => 'MWS foo:bar' } 47 | expect(mw_w_flag.mauth_client).to receive(:authentic?).and_return(true) 48 | expect(rack_app).to receive(:call).with(env.merge( 49 | MAuth::Client::RACK_ENV_APP_UUID_KEY => 'foo', 50 | 'mauth.authentic' => true, 51 | 'mauth.protocol_version' => 1 52 | )).and_return(res) 53 | status, _headers, body = mw_w_flag.call(env) 54 | expect(status).to eq(200) 55 | expect(body).to eq(['hello world']) 56 | end 57 | end 58 | 59 | it 'returns 401 and does not call the app if authentication fails' do 60 | expect(mw.mauth_client).to receive(:authentic?).and_return(false) 61 | expect(rack_app).not_to receive(:call) 62 | status, _headers, body = mw.call({ 'REQUEST_METHOD' => 'GET' }) 63 | expect(401).to eq(status) 64 | expect(body.join).to match(/Unauthorized/) 65 | end 66 | 67 | it 'returns 401 with no body if the request method is HEAD and authentication fails' do 68 | expect(mw.mauth_client).to receive(:authentic?).and_return(false) 69 | expect(rack_app).not_to receive(:call) 70 | status, headers, body = mw.call({ 'REQUEST_METHOD' => 'HEAD' }) 71 | expect(headers['Content-Length'].to_i).to be > 0 72 | expect(401).to eq(status) 73 | expect([]).to eq(body) 74 | end 75 | 76 | it 'returns 500 and does not call the app if unable to authenticate' do 77 | expect(mw.mauth_client).to receive(:authentic?).and_raise(MAuth::UnableToAuthenticateError.new('')) 78 | expect(rack_app).not_to receive(:call) 79 | status, _headers, body = mw.call({ 'REQUEST_METHOD' => 'GET' }) 80 | expect(500).to eq(status) 81 | expect(body.join).to match(/Could not determine request authenticity/) 82 | end 83 | 84 | context 'the V2_ONLY_AUTHENTICATE flag is true and the request has only v1 headers' do 85 | let(:v2_only_authenticate) { true } 86 | 87 | it 'returns 401 with an informative message and does not call the app' do 88 | env = { 'HTTP_X_MWS_AUTHENTICATION' => 'MWS foo:bar', 'REQUEST_METHOD' => 'GET' } 89 | expect(mw.mauth_client).not_to receive(:authentic?) 90 | expect(rack_app).not_to receive(:call) 91 | status, headers, body = mw.call(env) 92 | expect(401).to eq(status) 93 | expect(headers['Content-Type']).to eq('application/json') 94 | expect(JSON.parse(body.join)).to eq({ 95 | 'type' => 'errors:mauth:missing_v2', 96 | 'title' => 'This service requires mAuth v2 mcc-authentication header. ' \ 97 | 'Upgrade your mAuth library and configure it properly.' 98 | }) 99 | end 100 | end 101 | end 102 | 103 | describe MAuth::Rack::RequestAuthenticationFaker do 104 | it 'does not call check authenticity for any request by default' do 105 | env = { 'HTTP_X_MWS_AUTHENTICATION' => 'MWS foo:bar' } 106 | expect(mw.mauth_client).not_to receive(:authentic?) 107 | expect(rack_app).to receive(:call).with(env.merge({ 108 | MAuth::Client::RACK_ENV_APP_UUID_KEY => 'foo', 109 | 'mauth.authentic' => true, 110 | 'mauth.protocol_version' => 1 111 | })).and_return(res) 112 | status, _headers, body = mw.call(env) 113 | expect(status).to eq(200) 114 | expect(body).to eq(['hello world']) 115 | end 116 | 117 | it 'calls the app when the request is set to be authentic' do 118 | described_class.authentic = true 119 | env = { 'HTTP_X_MWS_AUTHENTICATION' => 'MWS foo:bar' } 120 | expect(rack_app).to receive(:call).with(env.merge({ 121 | MAuth::Client::RACK_ENV_APP_UUID_KEY => 'foo', 122 | 'mauth.authentic' => true, 123 | 'mauth.protocol_version' => 1 124 | })).and_return(res) 125 | status, _headers, body = mw.call(env) 126 | expect(status).to eq(200) 127 | expect(body).to eq(['hello world']) 128 | end 129 | 130 | it 'does not call the app when the request is set to be inauthentic' do 131 | described_class.authentic = false 132 | env = { 'REQUEST_METHOD' => 'GET', 'HTTP_X_MWS_AUTHENTICATION' => 'MWS foo:bar' } 133 | mw.call(env) 134 | expect(rack_app).not_to receive(:call) 135 | end 136 | 137 | it 'returns appropriate responses when the request is set to be inauthentic' do 138 | described_class.authentic = false 139 | env = { 'REQUEST_METHOD' => 'GET', 'HTTP_X_MWS_AUTHENTICATION' => 'MWS foo:bar' } 140 | status, _headers, _body = mw.call(env) 141 | expect(status).to eq(401) 142 | end 143 | 144 | it 'after an inauthentic request, the next request is authentic by default' do 145 | described_class.authentic = false 146 | env = { 'REQUEST_METHOD' => 'GET', 'HTTP_X_MWS_AUTHENTICATION' => 'MWS foo:bar' } 147 | status, _headers, _body = mw.call(env) 148 | expect(status).to eq(401) 149 | status, _headers, _body = mw.call(env) 150 | expect(status).to eq(200) 151 | end 152 | end 153 | 154 | describe MAuth::Rack::ResponseSigner do 155 | include_examples MAuth::Middleware 156 | 157 | context 'request with v2 headers' do 158 | let(:env) do 159 | { 160 | 'HTTP_MCC_AUTHENTICATION' => 'MWSV2 foo:bar;', 161 | 'REQUEST_METHOD' => 'GET', 162 | 'mauth.protocol_version' => 2 163 | } 164 | end 165 | 166 | it 'signs the response with only v2' do 167 | allow(rack_app).to receive(:call).with(env).and_return(res) 168 | expect(mw.mauth_client).to receive(:signed_v2).with( 169 | an_instance_of(MAuth::Rack::Response) 170 | ).and_return(MAuth::Rack::Response.new(*res)) 171 | mw.call(env) 172 | end 173 | end 174 | 175 | context 'request with v1 headers' do 176 | let(:env) do 177 | { 178 | 'HTTP_X_MWS_AUTHENTICATION' => 'MWS foo:bar', 179 | 'REQUEST_METHOD' => 'GET', 180 | 'mauth.protocol_version' => 1 181 | } 182 | end 183 | 184 | it 'signs the response with only v1' do 185 | allow(rack_app).to receive(:call).with(env).and_return(res) 186 | expect(mw.mauth_client).to receive(:signed_v1).with( 187 | an_instance_of(MAuth::Rack::Response) 188 | ).and_return(MAuth::Rack::Response.new(*res)) 189 | mw.call(env) 190 | end 191 | end 192 | 193 | context 'request with invalid headers' do 194 | let(:env) do 195 | { 196 | 'HTTP_MCC_AUTHENTICATION' => 'MWSV500 foo:bar;', 197 | 'REQUEST_METHOD' => 'GET' 198 | } 199 | end 200 | 201 | it 'signs the response with the default headers' do 202 | allow(rack_app).to receive(:call).with(env).and_return(res) 203 | expect(mw.mauth_client).to receive(:signed).with( 204 | an_instance_of(MAuth::Rack::Response) 205 | ).and_return(MAuth::Rack::Response.new(*res)) 206 | mw.call(env) 207 | end 208 | end 209 | end 210 | 211 | describe MAuth::Rack::Response do 212 | let(:status) { 200 } 213 | let(:headers) { {} } 214 | let(:body) { %w[hello world] } 215 | let(:response) { described_class.new(status, headers, body) } 216 | 217 | describe '#status_headers_body' do 218 | it 'returns status, headers and body' do 219 | expect(response.status_headers_body).to eq([status, headers, body]) 220 | end 221 | end 222 | 223 | describe '#attributes_for_signing' do 224 | it 'returns attributes_for_signing' do 225 | expect(response.attributes_for_signing).to eq(status_code: 200, body: 'helloworld') 226 | end 227 | end 228 | 229 | describe '#merge_headers' do 230 | it 'merges headers' do 231 | expect(response.merge_headers('foo' => 'bar').status_headers_body).to eq([status, { 'foo' => 'bar' }, body]) 232 | end 233 | end 234 | end 235 | end 236 | 237 | describe MAuth::Faraday do 238 | describe MAuth::Faraday::ResponseAuthenticator do 239 | include_examples MAuth::Middleware 240 | let(:faraday_app) do 241 | proc do 242 | res = Object.new 243 | def res.on_complete 244 | response_env = Faraday::Env.new 245 | response_env[:status] = 200 246 | response_env[:response_headers] = { 'x-mws-authentication' => 'MWS foo:bar' } 247 | response_env[:body] = 'hello world' 248 | yield(response_env) 249 | end 250 | res 251 | end 252 | end 253 | let(:mw) { described_class.new(faraday_app) } 254 | 255 | it 'returns the response with env indicating authenticity when authentic' do 256 | allow(mw.mauth_client).to receive(:authenticate!) 257 | res = mw.call({}) 258 | expect(200).to eq(res[:status]) 259 | expect('foo').to eq(res[MAuth::Client::RACK_ENV_APP_UUID_KEY]) 260 | expect(true).to eq(res['mauth.authentic']) 261 | end 262 | 263 | it 'raises InauthenticError on inauthentic response' do 264 | allow(mw.mauth_client).to receive(:authenticate!).and_raise(MAuth::InauthenticError.new) 265 | expect { mw.call({}) }.to raise_error(MAuth::InauthenticError) 266 | end 267 | 268 | it 'raises UnableToAuthenticateError when unable to authenticate' do 269 | allow(mw.mauth_client).to receive(:authenticate!).and_raise(MAuth::UnableToAuthenticateError.new) 270 | expect { mw.call({}) }.to raise_error(MAuth::UnableToAuthenticateError) 271 | end 272 | 273 | it 'is usable via the name mauth_response_authenticator' do 274 | # if this doesn't error, that's fine; means it looked up the middleware and is using it 275 | Faraday::Connection.new do |conn| 276 | conn.response :mauth_response_authenticator 277 | conn.adapter Faraday.default_adapter 278 | end 279 | end 280 | end 281 | 282 | describe MAuth::Faraday::RequestSigner do 283 | include_examples MAuth::Middleware 284 | 285 | it 'is usable via the name mauth_request_signer' do 286 | # if this doesn't error, that's fine; means it looked up the middleware and is using it 287 | Faraday::Connection.new do |conn| 288 | conn.request :mauth_request_signer 289 | conn.adapter Faraday.default_adapter 290 | end 291 | end 292 | end 293 | 294 | describe MAuth::Faraday::MAuthClientUserAgent do 295 | let(:fake_app) do 296 | Class.new do 297 | def call(env); end 298 | end 299 | end 300 | let(:agent_base) { 'Sallust' } 301 | let(:app) { fake_app.new } 302 | let(:middleware) { described_class.new(app, agent_base) } 303 | 304 | it 'sets the User-Agent request header' do 305 | request_headers = {} 306 | request_env = {} 307 | request_env[:request_headers] = request_headers 308 | middleware.call(request_env) 309 | expected = "#{agent_base} (MAuth-Client: #{MAuth::VERSION}; Ruby: #{RUBY_VERSION}; platform: #{RUBY_PLATFORM})" 310 | expect(request_headers['User-Agent']).to eq(expected) 311 | end 312 | end 313 | end 314 | -------------------------------------------------------------------------------- /spec/client/authenticator_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'faraday' 5 | require 'mauth/client' 6 | require_relative '../support/shared_contexts/client' 7 | require_relative '../support/shared_examples/authenticator' 8 | 9 | describe MAuth::Client::Authenticator do 10 | include_context 'client' 11 | 12 | describe '#authentic?' do 13 | let(:v2_only_authenticate) { false } 14 | let(:authenticating_mc) do 15 | MAuth::Client.new( 16 | mauth_baseurl: 'http://whatever', 17 | mauth_api_version: 'v1', 18 | private_key: OpenSSL::PKey::RSA.generate(2048), 19 | app_uuid: 'authenticator', 20 | v2_only_authenticate: v2_only_authenticate, 21 | disable_fallback_to_v1_on_v2_failure: disable_fallback_to_v1_on_v2_failure 22 | ) 23 | end 24 | let(:test_faraday) do 25 | Faraday.new do |builder| 26 | builder.adapter(:test, stubs) do |stub| 27 | stub.get("/mauth/v1/security_tokens/#{app_uuid}.json") do 28 | [200, {}, JSON.generate({ 'security_token' => { 'public_key_str' => signing_key.public_key.to_s } })] 29 | end 30 | end 31 | end 32 | end 33 | let(:stubs) { Faraday::Adapter::Test::Stubs.new } 34 | 35 | before do 36 | expect(authenticating_mc).to be_kind_of(MAuth::Client::Authenticator) 37 | allow(Faraday).to receive(:new).and_return(test_faraday) 38 | end 39 | 40 | include_examples MAuth::Client::Authenticator 41 | 42 | context 'when authenticating with v1' do 43 | it 'considers an authentically-signed request to be authentic' do 44 | expect(authenticating_mc.authentic?(v1_signed_req)).to be true 45 | end 46 | 47 | # NOTE: We need this feature because some web servers (e.g. nginx) unescape 48 | # URIs in PATH_INFO before sending them along to the served applications. This added to the 49 | # fact that Euresource percent-encodes just about everything in the path except '/' leads to 50 | # this somewhat odd test. 51 | it "considers a request to be authentic even if the request_url must be CGI::escape'ed (after being escaped in " \ 52 | "Euresource's own idiosyncratic way) before authenticity is achieved" do 53 | ['/v1/users/pjones+1@mdsol.com', "! # $ & ' ( ) * + , / : ; = ? @ [ ]"].each do |path| 54 | # imagine what are on the requester's side now... 55 | signed_path = CGI.escape(path).gsub!(/%2F|%23/, '%2F' => '/', '%23' => '#') # This is what Euresource does to the path on the requester's side before the signing of the outgoing request occurs. 56 | req_w_path = TestSignableRequest.new(verb: 'GET', request_url: signed_path) 57 | signed_request = client.signed_v1(req_w_path) 58 | 59 | # now that we've signed the request, imagine it goes to nginx where it gets percent-decoded 60 | decoded_signed_request = signed_request.clone 61 | decoded_signed_request.attributes_for_signing[:request_url] = 62 | CGI.unescape(decoded_signed_request.attributes_for_signing[:request_url]) 63 | expect(authenticating_mc.authentic?(decoded_signed_request)).to be true 64 | end 65 | end 66 | 67 | # And the above example inspires a slightly less unusual case, in which the path is fully percent-encoded 68 | it "considers a request to be authentic even if the request_url must be CGI::escape'ed before authenticity is " \ 69 | 'achieved' do 70 | ['/v1/users/pjones+1@mdsol.com', "! # $ & ' ( ) * + , / : ; = ? @ [ ]"].each do |path| 71 | # imagine what are on the requester's side now... 72 | signed_path = CGI.escape(path) 73 | req_w_path = TestSignableRequest.new(verb: 'GET', request_url: signed_path) 74 | signed_request = client.signed_v1(req_w_path) 75 | 76 | # now that we've signed the request, imagine it goes to nginx where it gets percent-decoded 77 | decoded_signed_request = signed_request.clone 78 | decoded_signed_request.attributes_for_signing[:request_url] = 79 | CGI.unescape(decoded_signed_request.attributes_for_signing[:request_url]) 80 | expect(authenticating_mc.authentic?(decoded_signed_request)).to be true 81 | end 82 | end 83 | 84 | it 'considers a request signed by an app uuid unknown to mauth to be inauthentic' do 85 | bad_client = MAuth::Client.new(private_key: signing_key, app_uuid: 'nope') 86 | signed_request = bad_client.signed_v1(request) 87 | stubs.get('/mauth/v1/security_tokens/nope.json') { [404, {}, []] } 88 | expect(authenticating_mc.authentic?(signed_request)).to be_falsey 89 | end 90 | 91 | it 'considers a request with a bad signature to be inauthentic' do 92 | v1_signed_req.headers['X-MWS-Authentication'] = "MWS #{app_uuid}:wat" 93 | expect(authenticating_mc.authentic?(v1_signed_req)).to be_falsey 94 | end 95 | 96 | it 'considers a request that has been tampered with to be inauthentic' do 97 | v1_signed_req.attributes_for_signing[:verb] = 'DELETE' 98 | expect(authenticating_mc.authentic?(v1_signed_req)).to be_falsey 99 | end 100 | end 101 | 102 | context 'when authenticating with v2' do 103 | let(:qs_request) do 104 | TestSignableRequest.new( 105 | verb: 'PUT', 106 | request_url: '/', 107 | body: 'himom', 108 | query_string: 'key=value&coolkey=coolvalue' 109 | ) 110 | end 111 | let(:binary_request) do 112 | TestSignableRequest.new( 113 | verb: 'PUT', 114 | request_url: '/', 115 | body: binary_file_body, 116 | query_string: 'key=value&coolkey=coolvalue' 117 | ) 118 | end 119 | let(:binary_filepath) { 'spec/fixtures/blank.jpeg' } 120 | let(:binary_file_body) { File.binread(binary_filepath) } 121 | let(:v2_only_authenticate) { true } 122 | 123 | it 'considers an authentically-signed request to be authentic' do 124 | signed_request = client.signed(request) 125 | expect(authenticating_mc.authentic?(signed_request)).to be true 126 | end 127 | 128 | it 'considers an authentically signed request with query parameters to be authentic' do 129 | signed_request = client.signed(qs_request) 130 | expect(authenticating_mc.authentic?(signed_request)).to be true 131 | end 132 | 133 | # NOTE: We need this feature because some web servers (e.g. nginx) unescape 134 | # URIs in PATH_INFO before sending them along to the served applications. This added to the 135 | # fact that Euresource percent-encodes just about everything in the path except '/' leads to 136 | # this somewhat odd test. 137 | it "considers a request with query parameters to be authentic even if the request_url must be CGI::escape'ed " \ 138 | "(after being escaped in Euresource's own idiosyncratic way) before authenticity is achieved" do 139 | [ 140 | ['/v1/users/pjones+1@mdsol.com', 'nice=cool&good=great'], 141 | ["! # $ & ' ( ) * + , / : ; = ? @ [ ]", "param=\\'df+P=%5C"] 142 | ].each do |path, qs| 143 | # imagine what are on the requester's side now... 144 | signed_path = CGI.escape(path).gsub(/%2F|%23/, '%2F' => '/', '%23' => '#') # This is what Euresource does to the path on the requester's side before the signing of the outgoing request occurs. 145 | signed_qs = CGI.escape(qs).gsub(/%3D|%26/, '%3D' => '=', '%26' => '&') 146 | req_w_path = TestSignableRequest.new(verb: 'GET', request_url: signed_path, query_string: signed_qs) 147 | signed_request = client.signed(req_w_path) 148 | 149 | # now that we've signed the request, imagine it goes to nginx where it gets percent-decoded 150 | decoded_signed_request = signed_request.clone 151 | decoded_signed_request.attributes_for_signing[:request_url] = 152 | CGI.unescape(decoded_signed_request.attributes_for_signing[:request_url]) 153 | decoded_signed_request.attributes_for_signing[:query_string] = 154 | CGI.unescape(decoded_signed_request.attributes_for_signing[:query_string]) 155 | expect(authenticating_mc.authentic?(decoded_signed_request)).to be true 156 | end 157 | end 158 | 159 | # And the above example inspires a slightly less unusual case, in which the path is fully percent-encoded 160 | it "considers a request with query parameters to be authentic even if the request_url must be CGI::escape'ed " \ 161 | 'before authenticity is achieved' do 162 | [ 163 | ['/v1/users/pjones+1@mdsol.com', 'nice=cool&good=great'], 164 | ["! # $ & ' ( ) * + , / : ; = ? @ [ ]", "param=\\'df+P=%5C"] 165 | ].each do |path, qs| 166 | # imagine what are on the requester's side now... 167 | signed_path = CGI.escape(path) 168 | signed_qs = CGI.escape(qs) 169 | req_w_path = TestSignableRequest.new(verb: 'GET', request_url: signed_path, query_string: signed_qs) 170 | signed_request = client.signed(req_w_path) 171 | 172 | # now that we've signed the request, imagine it goes to nginx where it gets percent-decoded 173 | decoded_signed_request = signed_request.clone 174 | decoded_signed_request.attributes_for_signing[:request_url] = 175 | CGI.unescape(decoded_signed_request.attributes_for_signing[:request_url]) 176 | decoded_signed_request.attributes_for_signing[:query_string] = 177 | CGI.unescape(decoded_signed_request.attributes_for_signing[:query_string]) 178 | expect(authenticating_mc.authentic?(decoded_signed_request)).to be true 179 | end 180 | end 181 | 182 | it 'considers a request signed by an app uuid unknown to mauth to be inauthentic' do 183 | bad_client = MAuth::Client.new(private_key: signing_key, app_uuid: 'nope') 184 | signed_request = bad_client.signed(request) 185 | stubs.get('/mauth/v1/security_tokens/nope.json') { [404, {}, []] } 186 | expect(authenticating_mc.authentic?(signed_request)).to be_falsey 187 | end 188 | 189 | it 'considers a request with a bad signature to be inauthentic' do 190 | signed_request = client.signed(request) 191 | signed_request.headers['MCC-Authentication'] = "MWS #{app_uuid}:wat" 192 | expect(authenticating_mc.authentic?(signed_request)).to be_falsey 193 | end 194 | 195 | it 'considers a request that has been tampered with to be inauthentic' do 196 | signed_request = client.signed(request) 197 | signed_request.attributes_for_signing[:verb] = 'DELETE' 198 | expect(authenticating_mc.authentic?(signed_request)).to be_falsey 199 | end 200 | 201 | it 'considers a request with many repeated query params authentic' do 202 | pairs = (1..100).reduce([]) do |acc, el| 203 | acc.push(['param1', el], ['param2', el]) 204 | end.shuffle 205 | 206 | request = TestSignableRequest.new( 207 | verb: 'PUT', 208 | request_url: '/', 209 | body: 'himom', 210 | query_string: pairs.map { |pair| pair.join('=') }.join('&') 211 | ) 212 | signed_request = client.signed(request) 213 | expect(authenticating_mc.authentic?(signed_request)).to be true 214 | end 215 | 216 | it 'considers a signed request with multi-byte UTF-8 characters in the query string to be authentic' do 217 | request = TestSignableRequest.new( 218 | verb: 'PUT', 219 | request_url: '/', 220 | body: 'himom', 221 | query_string: 'prm=val&prm=𝖛𝗮ḷ&パラメータ=値&매개 변수=값&參數=值' 222 | ) 223 | signed_request = client.signed(request) 224 | expect(authenticating_mc.authentic?(signed_request)).to be true 225 | end 226 | 227 | it 'considers a signed request with repeated query param keys with multi-byte UTF-8 character values to be " \ 228 | "authentic' do 229 | qs = 'prm=パ&prm=개' 230 | 231 | request = TestSignableRequest.new( 232 | verb: 'PUT', 233 | request_url: '/', 234 | body: 'himom', 235 | query_string: qs 236 | ) 237 | signed_request = client.signed(request) 238 | expect(authenticating_mc.authentic?(signed_request)).to be true 239 | end 240 | 241 | it 'considers a signed request with a request body of binary data to be authentic' do 242 | signed_request = client.signed(binary_request) 243 | expect(authenticating_mc.authentic?(signed_request)).to be true 244 | end 245 | 246 | it 'considers a signed request with a request body of binary data that was read in from disk to be authentic' do 247 | # the signing mauth client should be able to stream large request bodies 248 | # from the disk straight into the hashing function like so: 249 | streamed_hash_digest = OpenSSL::Digest::SHA512.file(binary_filepath).hexdigest 250 | # used the digest from streaming in the file when signing the request 251 | signed_request = client.signed(binary_request, body_digest: streamed_hash_digest) 252 | expect(authenticating_mc.authentic?(signed_request)).to be true 253 | end 254 | 255 | it 'considers a request with the wrong body_digest to be inauthentic' do 256 | wrong_hash_digest = OpenSSL::Digest.hexdigest('SHA512', 'abc') 257 | signed_request = client.signed(binary_request, body_digest: wrong_hash_digest) 258 | expect(authenticating_mc.authentic?(signed_request)).to be false 259 | end 260 | end 261 | end 262 | 263 | describe MAuth::Client::Authenticator::SecurityTokenCacher do 264 | describe '#signed_mauth_connection' do 265 | it 'properly sets the timeouts on the faraday connection' do 266 | config = { 267 | 'private_key' => OpenSSL::PKey::RSA.generate(2048), 268 | 'faraday_options' => { 'timeout' => '23', 'open_timeout' => '18' }, 269 | 'mauth_baseurl' => 'https://mauth.imedidata.net' 270 | } 271 | mc = MAuth::Client.new(config) 272 | connection = MAuth::Client::Authenticator::SecurityTokenCacher.new(mc).send(:signed_mauth_connection) 273 | expect(connection.options[:timeout]).to eq('23') 274 | expect(connection.options[:open_timeout]).to eq('18') 275 | end 276 | end 277 | end 278 | end 279 | -------------------------------------------------------------------------------- /spec/support/shared_examples/authenticator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | shared_examples MAuth::Client::Authenticator do 4 | context 'when v2 and v1 headers are present on the object to authenticate' do 5 | it 'authenticates with v2' do 6 | signed_request = client.signed(request) 7 | expect(authenticating_mc).to receive(:signature_valid_v2!).with(signed_request) 8 | expect(authenticating_mc).not_to receive(:signature_valid_v1!) 9 | authenticating_mc.authentic?(signed_request) 10 | end 11 | 12 | it "considers an authentically-signed request to be inauthentic when it's too old or too far in the future" do 13 | [-301, 301].each do |time_offset| 14 | signed_request = client.signed(request, time: Time.now.to_i + time_offset) 15 | message = "expected request signed at #{time_offset} seconds to be inauthentic" 16 | expect { authenticating_mc.authenticate!(signed_request) }.to( 17 | raise_error(MAuth::InauthenticError, /Time verification failed\. .* not within 300 of/), 18 | message 19 | ) 20 | end 21 | end 22 | 23 | it "considers an authentically-signed request to be authentic when it's within the allowed time range" do 24 | Timecop.freeze(Time.now) 25 | [-300, -299, 299, 300].each do |time_offset| 26 | signed_request = client.signed(request, time: Time.now.to_i + time_offset) 27 | message = "expected request signed at #{time_offset} seconds to be authentic" 28 | expect(authenticating_mc.authentic?(signed_request)).to eq(true), message 29 | end 30 | Timecop.return 31 | end 32 | 33 | context 'v2_only_authenticate flag is set to true' do 34 | let(:v2_only_authenticate) { true } 35 | 36 | it 'considers an authentically-signed request to be inauthentic when it has no MCC-time' do 37 | signed_request = client.signed(request) 38 | signed_request.headers.delete('MCC-Time') 39 | expect { authenticating_mc.authenticate!(signed_request) }.to raise_error( 40 | MAuth::InauthenticError, 41 | /Time verification failed\. No MCC-Time present\./ 42 | ) 43 | end 44 | 45 | it 'considers a request with a bad V2 token to be inauthentic' do 46 | ['mws2', 'm.w.s', 'm w s', 'NWSv2', ' MWS'].each do |bad_token| 47 | signed_request = client.signed(request) 48 | signed_request.headers['MCC-Authentication'] = 49 | signed_request.headers['MCC-Authentication'].sub(/\AMWSV2/, bad_token) 50 | expect { authenticating_mc.authenticate!(signed_request) }.to raise_error( 51 | MAuth::InauthenticError, /Token verification failed\. Expected MWSV2; token was .*/ 52 | ) 53 | end 54 | end 55 | end 56 | 57 | context 'v2_only_authenticate flag is set to false' do 58 | let(:v2_only_authenticate) { false } 59 | 60 | it 'falls back to V1 when it has no MCC-time' do 61 | signed_request = client.signed(request) 62 | signed_request.headers.delete('MCC-Time') 63 | 64 | expect(authenticating_mc.logger).to receive(:info).with( 65 | 'Mauth-client attempting to authenticate request from app with mauth app uuid ' \ 66 | 'signer to app with mauth app uuid authenticator using version MWS.' 67 | ) 68 | expect(authenticating_mc.logger).to receive(:warn).with( 69 | 'Completed successful authentication attempt after fallback to v1' 70 | ) 71 | 72 | expect { authenticating_mc.authenticate!(signed_request) }.not_to raise_error 73 | end 74 | 75 | it 'falls back to V1 when it has a bad V2 token' do 76 | ['mws2', 'm.w.s', 'm w s', 'NWSv2', ' MWS'].each do |bad_token| 77 | signed_request = client.signed(request) 78 | signed_request.headers['MCC-Authentication'] = 79 | signed_request.headers['MCC-Authentication'].sub(/\AMWSV2/, bad_token) 80 | 81 | expect(authenticating_mc.logger).to receive(:info).with( 82 | 'Mauth-client attempting to authenticate request from app with mauth app uuid ' \ 83 | 'signer to app with mauth app uuid authenticator using version MWS.' 84 | ) 85 | expect(authenticating_mc.logger).to receive(:warn).with( 86 | 'Completed successful authentication attempt after fallback to v1' 87 | ) 88 | 89 | expect { authenticating_mc.authenticate!(signed_request) }.not_to raise_error 90 | end 91 | end 92 | 93 | it 'considers a request to be inauthentic when it has no MCC-time, ' \ 94 | 'and V1 header (X-MWS-Authentication) is missing' do 95 | signed_request = client.signed(request) 96 | signed_request.headers.delete('MCC-Time') 97 | signed_request.headers.delete('X-MWS-Authentication') 98 | expect { authenticating_mc.authenticate!(signed_request) }.to raise_error( 99 | MAuth::InauthenticError, 100 | /Time verification failed\. No MCC-Time present\./ 101 | ) 102 | end 103 | 104 | context 'disable_fallback_to_v1_on_v2_failure flag is set to true' do 105 | let(:disable_fallback_to_v1_on_v2_failure) { true } 106 | 107 | it 'does not fall back to V1 when it has no MCC-time' do 108 | signed_request = client.signed(request) 109 | signed_request.headers.delete('MCC-Time') 110 | 111 | expect { authenticating_mc.authenticate!(signed_request) }.to raise_error( 112 | MAuth::InauthenticError, 113 | /Time verification failed\. No MCC-Time present\./ 114 | ) 115 | end 116 | 117 | it 'falls back to V1 when it has a bad V2 token' do 118 | signed_request = client.signed(request) 119 | signed_request.headers['MCC-Authentication'] = 120 | signed_request.headers['MCC-Authentication'].sub(/\AMWSV2/, 'mws2') 121 | 122 | expect { authenticating_mc.authenticate!(signed_request) }.to raise_error( 123 | MAuth::InauthenticError, 124 | /Token verification failed\./ 125 | ) 126 | end 127 | end 128 | end 129 | 130 | describe 'logging requester and requestee' do 131 | before do 132 | allow(authenticating_mc).to receive(:client_app_uuid).and_return('authenticator') 133 | end 134 | 135 | it 'logs the mauth app uuid of the requester and requestee when they both have such uuids' do 136 | signed_request = client.signed(request, time: Time.now.to_i) 137 | expect(authenticating_mc.logger).to receive(:info).with( 138 | 'Mauth-client attempting to authenticate request from app with mauth ' \ 139 | 'app uuid signer to app with mauth app uuid authenticator using version MWSV2.' 140 | ) 141 | authenticating_mc.authentic?(signed_request) 142 | end 143 | 144 | it 'logs when the mauth app uuid is not provided in the request' do 145 | signed_request = client.signed(request, time: Time.now.to_i) 146 | allow(signed_request).to receive(:signature_app_uuid).and_return(nil) 147 | expect(authenticating_mc.logger).to receive(:info).with( 148 | 'Mauth-client attempting to authenticate request from app with mauth app uuid ' \ 149 | '[none provided] to app with mauth app uuid authenticator using version MWSV2.' 150 | ) 151 | authenticating_mc.authentic?(signed_request) rescue nil 152 | end 153 | end 154 | end 155 | 156 | context 'when only v1 headers are present on the object to authenticate' do 157 | it 'authenticates with v1' do 158 | expect(authenticating_mc).to receive(:signature_valid_v1!).with(v1_signed_req) 159 | expect(authenticating_mc).not_to receive(:signature_valid_v2!) 160 | authenticating_mc.authentic?(v1_signed_req) 161 | end 162 | 163 | it "considers an authentically-signed request to be inauthentic when it's too old or too far in the future" do 164 | [-301, 301].each do |time_offset| 165 | signed_request = client.signed_v1(request, time: Time.now.to_i + time_offset) 166 | message = "expected request signed at #{time_offset} seconds to be inauthentic" 167 | expect { authenticating_mc.authenticate!(signed_request) }.to( 168 | raise_error(MAuth::InauthenticError, /Time verification failed\. .* not within 300 of/), 169 | message 170 | ) 171 | end 172 | end 173 | 174 | it "considers an authentically-signed request to be authentic when it's within the allowed time range" do 175 | Timecop.freeze(Time.now) 176 | [-300, -299, 299, 300].each do |time_offset| 177 | signed_request = client.signed_v1(request, time: Time.now.to_i + time_offset) 178 | message = "expected request signed at #{time_offset} seconds to be authentic" 179 | expect(authenticating_mc.authentic?(signed_request)).to eq(true), message 180 | end 181 | Timecop.return 182 | end 183 | 184 | it 'considers an authentically-signed request to be inauthentic when it has no x-mws-time' do 185 | v1_signed_req.headers.delete('X-MWS-Time') 186 | expect { authenticating_mc.authenticate!(v1_signed_req) }.to raise_error( 187 | MAuth::InauthenticError, 188 | /Time verification failed\. No x-mws-time present\./ 189 | ) 190 | end 191 | 192 | it 'considers a request with a bad MWS token to be inauthentic' do 193 | ['mws', 'm.w.s', 'm w s', 'NWS', ' MWS'].each do |bad_token| 194 | v1_signed_req.headers['X-MWS-Authentication'] = 195 | v1_signed_req.headers['X-MWS-Authentication'].sub(/\AMWS/, bad_token) 196 | expect { authenticating_mc.authenticate!(v1_signed_req) }.to raise_error( 197 | MAuth::InauthenticError, /Token verification failed\. Expected MWS; token was .*/ 198 | ) 199 | end 200 | end 201 | 202 | [Faraday::ConnectionFailed, Faraday::TimeoutError].each do |error_klass| 203 | it "raises UnableToAuthenticate if mauth is unreachable with #{error_klass.name}" do 204 | allow(test_faraday).to receive(:get).and_raise(error_klass.new('')) 205 | expect { authenticating_mc.authentic?(v1_signed_req) }.to raise_error(MAuth::UnableToAuthenticateError) 206 | end 207 | end 208 | 209 | it 'raises UnableToAuthenticate if mauth errors' do 210 | stubs.instance_eval { @stack.clear } # HAX 211 | stubs.get("/mauth/v1/security_tokens/#{app_uuid}.json") { [500, {}, []] } 212 | expect { authenticating_mc.authentic?(v1_signed_req) }.to raise_error(MAuth::UnableToAuthenticateError) 213 | end 214 | 215 | describe 'logging requester and requestee' do 216 | before do 217 | allow(authenticating_mc).to receive(:client_app_uuid).and_return('authenticator') 218 | end 219 | 220 | it 'logs the mauth app uuid of the requester and requestee when they both have such uuids' do 221 | expect(authenticating_mc.logger).to receive(:info).with( 222 | 'Mauth-client attempting to authenticate request from app with mauth app ' \ 223 | 'uuid signer to app with mauth app uuid authenticator using version MWS.' 224 | ) 225 | authenticating_mc.authentic?(v1_signed_req) 226 | end 227 | 228 | it 'logs when the mauth app uuid is not provided in the request' do 229 | allow(v1_signed_req).to receive(:signature_app_uuid).and_return(nil) 230 | expect(authenticating_mc.logger).to receive(:info).with( 231 | 'Mauth-client attempting to authenticate request from app with mauth app ' \ 232 | 'uuid [none provided] to app with mauth app uuid authenticator using version MWS.' 233 | ) 234 | authenticating_mc.authentic?(v1_signed_req) rescue nil 235 | end 236 | end 237 | end 238 | 239 | context 'when no headers are present on the object to authenticate' do 240 | it 'considers a request without v1 and v2 headers to be inauthentic' do 241 | signed_request = client.signed(request) 242 | signed_request.headers.delete('X-MWS-Authentication') 243 | signed_request.headers.delete('MCC-Authentication') 244 | expect { authenticating_mc.authenticate!(signed_request) }.to raise_error( 245 | MAuth::MAuthNotPresent, 246 | 'Authentication Failed. No mAuth signature present; X-MWS-Authentication ' \ 247 | 'header is blank, MCC-Authentication header is blank.' 248 | ) 249 | end 250 | 251 | it 'considers a request with empty v1 and v2 headers to be inauthentic' do 252 | signed_request = client.signed(request) 253 | signed_request.headers['X-MWS-Authentication'] = '' 254 | signed_request.headers['MCC-Authentication'] = '' 255 | expect { authenticating_mc.authenticate!(signed_request) }.to raise_error( 256 | MAuth::MAuthNotPresent, 257 | 'Authentication Failed. No mAuth signature present; X-MWS-Authentication ' \ 258 | 'header is blank, MCC-Authentication header is blank.' 259 | ) 260 | end 261 | end 262 | 263 | context 'when v2_only_authenticate flag is true' do 264 | let(:v2_only_authenticate) { true } 265 | 266 | it 'authenticates with v2' do 267 | signed_request = client.signed(request) 268 | expect(authenticating_mc).to receive(:signature_valid_v2!).with(signed_request) 269 | expect(authenticating_mc).not_to receive(:signature_valid_v1!) 270 | authenticating_mc.authentic?(signed_request) 271 | end 272 | 273 | it 'raises MissingV2Error if v2 headers are not present and v1 headers are present' do 274 | expect { authenticating_mc.authenticate!(v1_signed_req) }.to raise_error( 275 | MAuth::MissingV2Error 276 | ) 277 | end 278 | 279 | it 'considers a request without v2 or v1 headers to be inauthentic' do 280 | signed_request = client.signed(request) 281 | signed_request.headers.delete('MCC-Authentication') 282 | signed_request.headers.delete('X-MWS-Authentication') 283 | expect { authenticating_mc.authenticate!(signed_request) }.to raise_error( 284 | MAuth::MAuthNotPresent, 285 | 'Authentication Failed. No mAuth signature present; MCC-Authentication header is blank.' 286 | ) 287 | end 288 | 289 | it 'considers a request with an empty v2 header to be inauthentic' do 290 | signed_request = client.signed(request) 291 | signed_request.headers['MCC-Authentication'] = '' 292 | signed_request.headers.delete('X-MWS-Authentication') 293 | expect { authenticating_mc.authenticate!(signed_request) }.to raise_error( 294 | MAuth::MAuthNotPresent, 295 | 'Authentication Failed. No mAuth signature present; MCC-Authentication header is blank.' 296 | ) 297 | end 298 | end 299 | end 300 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MAuth-Client 2 | 3 | This gem consists of MAuth::Client, a class to manage the information needed to both sign and authenticate requests 4 | and responses, and middlewares for Rack and Faraday which leverage the client's capabilities. 5 | 6 | MAuth-Client exists in a variety of languages (.Net, Go, R etc.), see the [implementations list](doc/implementations.md) for more info. 7 | 8 | ## Installation 9 | 10 | Add this line to your application's Gemfile: 11 | 12 | ```ruby 13 | gem 'mauth-client' 14 | ``` 15 | 16 | And then execute: 17 | ``` 18 | $ bundle 19 | ``` 20 | 21 | Or install it yourself as: 22 | ``` 23 | $ gem install mauth-client 24 | ``` 25 | 26 | 27 | ## Configuration 28 | 29 | Configuration is set through environment variables: 30 | 31 | - `MAUTH_PRIVATE_KEY` 32 | - Required for signing and for authentication. 33 | 34 | - `MAUTH_PRIVATE_KEY_FILE` 35 | - May be used instead of `MAUTH_PRIVATE_KEY`, mauth-client will load the file instead. 36 | 37 | - `MAUTH_APP_UUID` 38 | - Required in the same circumstances where a `private_key` is required. 39 | 40 | - `MAUTH_URL` 41 | - Required for authentication but not for signing. Needed to retrieve public keys. Usually this is `https://mauth.imedidata.com` for production. 42 | 43 | - `MAUTH_API_VERSION` 44 | - Required for authentication but not for signing. only `v1` exists as of this writing. Defaults to `v1`. 45 | 46 | - `MAUTH_V2_ONLY_SIGN_REQUESTS` 47 | - If true, all outgoing requests will be signed with only the V2 protocol. Defaults to false. 48 | 49 | - `MAUTH_V2_ONLY_AUTHENTICATE` 50 | - If true, any incoming request or incoming response that does not use the V2 protocol will be rejected. Defaults to false. 51 | 52 | - `MAUTH_DISABLE_FALLBACK_TO_V1_ON_V2_FAILURE` 53 | - If true, any incoming V2 requests that fail authentication will not fall back to V1 authentication. Defaults to false. 54 | 55 | - `MAUTH_V1_ONLY_SIGN_REQUESTS` 56 | - If true, all outgoing requests will be signed with only the V1 protocol. Defaults to true. Note, cannot be `true` if `MAUTH_V2_ONLY_SIGN_REQUESTS` is also `true`. 57 | 58 | - `MAUTH_USE_RAILS_CACHE` 59 | - If true, `Rails.cache` is used to cache public keys for authentication. 60 | 61 | This is simply loaded and passed to either middleware or directly to a MAuth::Client instance. 62 | See the documentation for [MAuth::Client#initialize](lib/mauth/client.rb) for more details of what it accepts. Usually you will want: 63 | 64 | ```ruby 65 | MAUTH_CONF = MAuth::Client.default_config 66 | ``` 67 | 68 | The `.default_config` method takes a number of options to tweak its expectations regarding defaults. See the 69 | documentation for [MAuth::Client.default_config](lib/mauth/client.rb) for details. 70 | 71 | The `private_key` and `app_uuid` are required for signing and for authentication. 72 | They’ll only work if the `app_uuid` has been stored in MAuth with a public key corresponding to the `private_key`. 73 | 74 | The `mauth_baseurl` and `mauth_api_version` are required for authentication. 75 | These tell the MAuth-Client where and how to communicate with the MAuth service. 76 | 77 | The `v2_only_sign_requests` and `v2_only_authenticate` flags were added to facilitate conversion from the MAuth V1 protocol to the MAuth 78 | V2 protocol. By default both of these flags are false. See [Protocol Versions](#protocol-versions) below for more information about the different versions. 79 | 80 | | | v2_only_sign_requests | v2_only_authenticate | 81 | |-------|------------------------------------|--------------------------------------------------------------------------------------| 82 | | true | requests are signed with only V2 | requests and responses are authenticated with only V2 | 83 | | false | requests are signed with V1 and V2 | requests and responses are authenticated with the highest available protocol version | 84 | 85 | ### Generating keys 86 | 87 | To generate a private key (`mauth_key`) and its public counterpart (`mauth_key.pub`) run: 88 | 89 | ``` 90 | openssl genrsa -out mauth_key 2048 91 | openssl rsa -in mauth_key -pubout -out mauth_key.pub 92 | ``` 93 | 94 | ## Rack Middleware Usage 95 | 96 | MAuth-Client provides a middleware for request authentication and response verification in mauth/rack. 97 | 98 | ```ruby 99 | require 'mauth/rack' 100 | ``` 101 | 102 | If you are using other rack middlewares, the MAuth middleware MUST come FIRST in the stack of middlewares. 103 | This means it is closest to the HTTP layer, furthest from the application. 104 | If any other middlewares which modify the incoming request or outgoing response lie between the HTTP layer and the MAuth middleware, incoming requests will probably fail to authenticate and outgoing response signatures will be invalid (and fail when the requester tries to authenticate them). 105 | 106 | Using these middlewares in rails consists of calls to `config.middleware.use` in the appropriate place (see [the Rails Guides](http://guides.rubyonrails.org/rails_on_rack.html) for more info). 107 | 108 | Using the `MAuth::Rack::ResponseSigner` middleware is optional, but highly recommended. 109 | If used, this should come before the `MAuth::Rack::RequestAuthenticator` middleware. 110 | The ResponseSigner can be used ONLY if you have an `app_uuid` and `private_key` specified in your mauth configuration. 111 | 112 | ```ruby 113 | config.middleware.use MAuth::Rack::ResponseSigner, MAUTH_CONF 114 | ``` 115 | 116 | Then request authentication: 117 | 118 | ```ruby 119 | config.middleware.use MAuth::Rack::RequestAuthenticator, MAUTH_CONF 120 | ``` 121 | 122 | However, assuming you have a route `/app_status`, you probably want to skip request authentication for that. 123 | There is a middleware (`RequestAuthenticatorNoAppStatus`) to make that easier: 124 | 125 | ```ruby 126 | config.middleware.use MAuth::Rack::RequestAuthenticatorNoAppStatus, MAUTH_CONF 127 | ``` 128 | 129 | You may want to configure other conditions in which to bypass MAuth authentication. 130 | The middleware takes an option on the `:should_authenticate_check` key, which is a ruby proc that is passed to the request's rack env and must result in a boolean. 131 | If the result is true(ish), the middleware will authenticate the incoming request; if false, it will not. 132 | The `:should_authenticate_check` parameter is OPTIONAL. 133 | If omitted, all incoming requests will be authenticated. 134 | 135 | Here are a few example `:should_authenticate_check` procs: 136 | 137 | ```ruby 138 | MAUTH_CONF[:should_authenticate_check] = proc do |env| 139 | env['REQUEST_METHOD'] == 'GET' 140 | end 141 | config.middleware.use MAuth::Rack::RequestAuthenticator, MAUTH_CONF 142 | ``` 143 | 144 | Above, env is a hash of request parameters; this hash is generated by Rack. 145 | The above proc will force the middleware to authenticate only GET requests. 146 | 147 | 148 | Another example: 149 | 150 | ```ruby 151 | MAUTH_CONF[:should_authenticate_check] = proc do |env| 152 | env['PATH_INFO'] == '/studies.json' 153 | end 154 | config.middleware.use MAuth::Rack::RequestAuthenticator, MAUTH_CONF 155 | ``` 156 | 157 | The above proc will force the rack middleware to authenticate only requests to the "/studies.json" path. 158 | To authenticate a group of related URIs, considered matching `env['PATH_INFO']` with one or more regular expressions. 159 | 160 | The configuration passed to the middlewares in the above examples (`MAUTH_CONF`) is used create a new instance of `MAuth::Client`. 161 | If you are managing an MAuth::Client of your own for some reason, you can pass that in on the key `:mauth_client => your_client`, and omit any other MAuth::Client configuration. 162 | `:should_authenticate_check` is handled by the middleware and should still be specified alongside `:mauth_client`, if you are using it. 163 | 164 | When the request authentication middleware determines that a request is inauthentic, it will not call the application and will respond with a 401 status code along with an error, expressed in JSON 165 | (Content-Type: application/json) with the following value: 166 | ``` 167 | { "errors": { "mauth": ["Unauthorized"] } } 168 | ``` 169 | Successfully authenticated requests will be passed to the application, as will requests for which the `:should_authenticate_check` condition is false. 170 | 171 | If the middleware is unable to authenticate the request because MAuth is unavailable and so cannot serve public keys, it responds with a 500 status code and an error expressed in JSON with the value: 172 | ``` 173 | { "errors": { "mauth": ["Could not determine request authenticity"] } } 174 | ``` 175 | 176 | ## Examples 177 | 178 | Putting all this together, here are typical examples (in rails you would put that code in an initializer): 179 | 180 | ```ruby 181 | require 'mauth/rack' 182 | 183 | MAUTH_CONF = MAuth::Client.default_config 184 | 185 | # ResponseSigner OPTIONAL; only use if you are registered in mauth service 186 | Rails.application.config.middleware.insert_after Rack::Runtime, MAuth::Rack::ResponseSigner, MAUTH_CONF 187 | if Rails.env.test? || Rails.env.development? 188 | require 'mauth/fake/rack' 189 | Rails.application.config.middleware.insert_after MAuth::Rack::ResponseSigner, MAuth::Rack::RequestAuthenticationFaker, MAUTH_CONF 190 | else 191 | Rails.application.config.middleware.insert_after MAuth::Rack::ResponseSigner, MAuth::Rack::RequestAuthenticatorNoAppStatus, MAUTH_CONF 192 | end 193 | ``` 194 | 195 | With `:should_authenticate_check`: 196 | 197 | ```ruby 198 | require 'mauth/rack' 199 | 200 | MAUTH_CONF = MAuth::Client.default_config 201 | # authenticate all requests which pass the some_condition_of check and aren't /app_status with MAuth 202 | MAUTH_CONF[:should_authenticate_check] = proc do |env| 203 | some_condition_of(env) 204 | end 205 | 206 | # ResponseSigner OPTIONAL; only use if you are registered in mauth service 207 | Rails.application.config.middleware.insert_after Rack::Runtime, MAuth::Rack::ResponseSigner, MAUTH_CONF 208 | if Rails.env.test? || Rails.env.development? 209 | require 'mauth/fake/rack' 210 | Rails.application.config.middleware.insert_after MAuth::Rack::ResponseSigner, MAuth::Rack::RequestAuthenticationFaker, MAUTH_CONF 211 | else 212 | Rails.application.config.middleware.insert_after MAuth::Rack::ResponseSigner, MAuth::Rack::RequestAuthenticatorNoAppStatus, MAUTH_CONF 213 | end 214 | ``` 215 | 216 | ## Fake middleware 217 | 218 | For testing purposes, you may wish to use middleware which does not perform actual authentication. 219 | MAuth provides this, as `MAuth::Rack::RequestAuthenticationFaker`. 220 | Requests are still checked for the presence of an MAuth signature - this is necessary as many applications rely on the `app_uuid` identified in the signature, so it cannot be ignored entirely. 221 | However, the validity of the public key is not checked in the MAuth service, and the authenticity of the request is not verified by its signature. 222 | 223 | This example code may augment the above examples to disable authentication in test mode: 224 | 225 | ```ruby 226 | require 'mauth/fake/rack' 227 | authenticator = Rails.env != 'test' ? MAuth::Rack::RequestAuthenticator : MAuth::Rack::RequestAuthenticationFaker 228 | config.middleware.use authenticator, MAUTH_CONF 229 | ``` 230 | 231 | ## Faraday Middleware Usage 232 | 233 | If you are making outgoing HTTP requests using Faraday, adding MAuth Faraday middleware is much the same as adding rack middleware. 234 | Building your connection will look like: 235 | 236 | ```ruby 237 | Faraday.new(some_args) do |builder| 238 | builder.use MAuth::Faraday::RequestSigner, MAUTH_CONF 239 | builder.use MAuth::Faraday::ResponseAuthenticator, MAUTH_CONF 240 | builder.adapter Faraday.default_adapter 241 | end 242 | ``` 243 | 244 | The Faraday middleware MUST come LAST in the stack of middleware. 245 | As with the rack middleware, this means it will be right next to the HTTP adapter. 246 | 247 | Only use the `MAuth::Faraday::ResponseAuthenticator` middleware if you are expecting the service you are communicating with to sign its responses (all services which are aware of MAuth _should_ be doing this). 248 | 249 | `MAUTH_CONF` is the same as in Rack middleware, and as with the Rack middleware is used to initialize a `MAuth::Client` instance. 250 | Also as with the Rack middleware, you can pass in a `MAuth::Client` instance you are using yourself on the `:mauth_client` key, and omit any other configuration. 251 | 252 | Both `MAuth::Faraday::ResponseAuthenticator` and `MAuth::Faraday::RequestSigner` cannot be used without a `private_key` and `app_uuid`. 253 | 254 | If a response which does not appear to be authentic is received by the `MAuth::Faraday::ResponseAuthenticator` middleware, a `MAuth::InauthenticError` will be raised. 255 | 256 | If the MAuth service cannot be reached, and therefore the authenticity of a response cannot be verified by ResponseAuthenticator, then a `MAuth::UnableToAuthenticateError` will be raised. 257 | 258 | ## Other Request and Response signing 259 | 260 | If you are not using Faraday, you will need to sign your own requests. 261 | 262 | Instantiate a `MAuth::Client` with the same configuration as the middlewares, as documented on [MAuth::Client#initialize](lib/mauth/client.rb). 263 | We'll call this `mauth_client`. 264 | 265 | `mauth_client` has a method `#signed_headers` which takes either a `MAuth::Request` or `MAuth::Response` object, and generates HTTP headers which can be added to the request or response to indicate authenticity. 266 | Create a `MAuth::Request` object from the information in your HTTP request, whatever its form: 267 | 268 | ```ruby 269 | require 'mauth/request_and_response' 270 | request = MAuth::Request.new(verb: my_verb, request_url: my_request_url, body: my_body, query_string: my_query_string) 271 | ``` 272 | `mauth_client.signed_headers(request)` will then return mauth headers which you can apply to your request. 273 | 274 | ## Warning 275 | 276 | During development classes are typically not cached in Rails applications. 277 | If this is the case, be aware that the MAuth-Client middleware object will be instantiated anew for each request; 278 | this will cause applications performing local authentication to fetch public keys before each request is authenticated. 279 | 280 | ## Protocol Versions 281 | 282 | The mauth V2 protocol was added as of v5.0.0. This protocol updates the string_to_sign to include query parameters, uses different authentication header names, and has a few other changes. See this document for more information: (DOC?). By default MAuth-Client will authenticate incoming requests with only the highest version of the protocol present, and sign their outgoing responses with only the version used to authenticate the request. By default MAuth-Client will sign outgoing requests with both the V1 and V2 protocols, and authenticate their incoming responses with only the highest version of the protocol present. 283 | If the `v2_only_sign_requests` flag is true all outgoing requests will be signed with only the V2 protocol (outgoing responses will still be signed with whatever protocol used to authenticate the request). If the `v2_only_authenticate` flag is true then MAuth-Client will reject any incoming request or incoming response that does not use the V2 protocol. 284 | --------------------------------------------------------------------------------