diff --git a/README.md b/README.md index 0d6fc236..3ed8bb81 100644 --- a/README.md +++ b/README.md @@ -159,6 +159,18 @@ Simply add this configuration to your Flexirest initializer in your app and it w Flexirest::Base.api_auth_credentials(@access_id, @secret_key) ``` +### Excon + +ApiAuth can also sign all requests made with [Excon](https://github.com/excon/excon). + +``` ruby +require 'api_auth/middleware/excon' + +Excon.defaults[:api_auth_access_id] = +Excon.defaults[:api_auth_secret_key] = +Excon.defaults[:middlewares] << ApiAuth::Middleware::Excon +``` + ## Server ApiAuth provides some built in methods to help you generate API keys for your diff --git a/lib/api_auth.rb b/lib/api_auth.rb index 96816fd3..b595dbe9 100644 --- a/lib/api_auth.rb +++ b/lib/api_auth.rb @@ -12,6 +12,7 @@ require 'api_auth/request_drivers/rack' require 'api_auth/request_drivers/httpi' require 'api_auth/request_drivers/faraday' +require 'api_auth/request_drivers/excon' require 'api_auth/headers' require 'api_auth/base' diff --git a/lib/api_auth/middleware/excon.rb b/lib/api_auth/middleware/excon.rb new file mode 100644 index 00000000..5380f45f --- /dev/null +++ b/lib/api_auth/middleware/excon.rb @@ -0,0 +1,49 @@ +module ApiAuth + module Middleware # :nodoc: + class Excon # :nodoc: + def initialize(stack) + @stack = stack + end + + def error_call(datum) + @stack.error_call(datum) + end + + def request_call(datum) + request = ExconRequestWrapper.new(datum, @stack.query_string(datum)) + ApiAuth.sign!(request, datum[:api_auth_access_id], datum[:api_auth_secret_key]) + + @stack.request_call(datum) + end + + def response_call(datum) + @stack.response_call(datum) + end + end + + class ExconRequestWrapper # :nodoc: + attr_reader :datum, :query_string + + def initialize(datum, query_string) + @datum = datum + @query_string = query_string + end + + def uri + datum[:path] + query_string + end + + def method + datum[:method] + end + + def headers + datum[:headers] + end + + def body + datum[:body] + end + end + end +end diff --git a/lib/api_auth/request_drivers/excon.rb b/lib/api_auth/request_drivers/excon.rb new file mode 100644 index 00000000..cbd3b973 --- /dev/null +++ b/lib/api_auth/request_drivers/excon.rb @@ -0,0 +1,72 @@ +module ApiAuth + module RequestDrivers # :nodoc: + class ExconRequest # :nodoc: + include ApiAuth::Helpers + + def initialize(request) + @request = request + end + + def set_auth_header(header) + @request.headers['Authorization'] = header + @request + end + + def calculated_md5 + md5_base64digest(@request.body || '') + end + + def populate_content_md5 + return unless @request.body + @request.headers['Content-MD5'] = calculated_md5 + end + + def md5_mismatch? + if @request.body + calculated_md5 != content_md5 + else + false + end + end + + def http_method + @request.method + end + + def content_type + find_header(%w[CONTENT-TYPE CONTENT_TYPE HTTP_CONTENT_TYPE]) + end + + def content_md5 + find_header(%w[CONTENT-MD5 CONTENT_MD5]) + end + + def original_uri + find_header(%w[X-ORIGINAL-URI X_ORIGINAL_URI HTTP_X_ORIGINAL_URI]) + end + + def request_uri + @request.uri + end + + def set_date + @request.headers['DATE'] = Time.now.utc.httpdate + end + + def timestamp + find_header(%w[DATE HTTP_DATE]) + end + + def authorization_header + find_header %w[Authorization AUTHORIZATION HTTP_AUTHORIZATION] + end + + private + + def find_header(keys) + headers = capitalize_keys(@request.headers) + keys.map { |key| headers[key] }.compact.first + end + end + end +end diff --git a/spec/request_drivers/excon_spec.rb b/spec/request_drivers/excon_spec.rb new file mode 100644 index 00000000..ab1fda60 --- /dev/null +++ b/spec/request_drivers/excon_spec.rb @@ -0,0 +1,158 @@ +require 'spec_helper' +require 'api_auth/middleware/excon' + +describe ApiAuth::RequestDrivers::ExconRequest do + let(:timestamp) { Time.now.utc.httpdate } + let(:body) { "hello\nworld" } + let(:method) { 'GET' } + let(:headers) do + { + 'Authorization' => 'APIAuth 1044:12345', + 'content-md5' => '1B2M2Y8AsgTpgAmY7PhCfg==', + 'content-type' => 'text/plain', + 'date' => timestamp + } + end + + let(:request) do + datum = { path: '/resource.xml', + method: method, + headers: headers, + body: body } + query_string = '?foo=bar&bar=foo' + + ApiAuth::Middleware::ExconRequestWrapper.new(datum, query_string) + end + + subject(:driven_request) { described_class.new(request) } + + describe 'getting headers correctly' do + it 'gets the content_type' do + expect(driven_request.content_type).to eq('text/plain') + end + + it 'gets the content_md5' do + expect(driven_request.content_md5).to eq('1B2M2Y8AsgTpgAmY7PhCfg==') + end + + it 'gets the request_uri' do + expect(driven_request.request_uri).to eq('/resource.xml?foo=bar&bar=foo') + end + + it 'gets the timestamp' do + expect(driven_request.timestamp).to eq(timestamp) + end + + it 'gets the authorization_header' do + expect(driven_request.authorization_header).to eq('APIAuth 1044:12345') + end + + describe '#calculated_md5' do + it 'calculates md5 from the body' do + expect(driven_request.calculated_md5).to eq('kZXQvrKoieG+Be1rsZVINw==') + end + + context 'no body' do + let(:body) { nil } + + it 'is treated as empty string' do + expect(driven_request.calculated_md5).to eq('1B2M2Y8AsgTpgAmY7PhCfg==') + end + end + end + + describe 'http_method' do + let(:method) { 'PUT' } + + it 'is as passed' do + expect(driven_request.http_method).to eq(method) + end + end + end + + describe 'setting headers correctly' do + let(:headers) { { 'content-type' => 'text/plain' } } + + describe '#populate_content_md5' do + context 'when there is no content body' do + let(:body) { nil } + + it "doesn't populate content-md5" do + driven_request.populate_content_md5 + expect(request.headers['Content-MD5']).to be_nil + end + end + + context 'when there is a content body' do + let(:body) { "hello\nworld" } + + it 'populates content-md5' do + driven_request.populate_content_md5 + expect(request.headers['Content-MD5']).to eq('kZXQvrKoieG+Be1rsZVINw==') + end + + it 'refreshes the cached headers' do + driven_request.populate_content_md5 + expect(driven_request.content_md5).to eq('kZXQvrKoieG+Be1rsZVINw==') + end + end + end + + describe '#set_date' do + before do + allow(Time).to receive_message_chain(:now, :utc, :httpdate).and_return(timestamp) + end + + it 'sets the date header of the request' do + driven_request.set_date + expect(request.headers['DATE']).to eq(timestamp) + end + + it 'refreshes the cached headers' do + driven_request.set_date + expect(driven_request.timestamp).to eq(timestamp) + end + end + + describe '#set_auth_header' do + it 'sets the auth header' do + driven_request.set_auth_header('APIAuth 1044:54321') + expect(request.headers['Authorization']).to eq('APIAuth 1044:54321') + end + end + end + + describe 'md5_mismatch?' do + context 'when there is no content body' do + let(:body) { nil } + + it 'is false' do + expect(driven_request.md5_mismatch?).to be false + end + end + + context 'when there is a content body' do + let(:body) { "hello\nworld" } + + context 'when calculated matches sent' do + before do + request.headers['Content-MD5'] = 'kZXQvrKoieG+Be1rsZVINw==' + end + + it 'is false' do + expect(driven_request.md5_mismatch?).to be false + end + end + + context "when calculated doesn't match sent" do + before do + request.headers['Content-MD5'] = '3' + end + + it 'is true' do + expect(driven_request.md5_mismatch?).to be true + end + end + end + end +end