diff --git a/lib/shift/api/core.rb b/lib/shift/api/core.rb index 26efcc1..a880088 100644 --- a/lib/shift/api/core.rb +++ b/lib/shift/api/core.rb @@ -4,6 +4,7 @@ require "shift/api/core/middleware" require "shift/api/core/request_id" require "shift/api/core/errors" +require "shift/api/core/models/create_token_from_api_key" module Shift module Api # diff --git a/lib/shift/api/core/config.rb b/lib/shift/api/core/config.rb index 3dd9a08..c72a070 100644 --- a/lib/shift/api/core/config.rb +++ b/lib/shift/api/core/config.rb @@ -54,6 +54,19 @@ class Config # defaults to {} # Can also be an object responding to :call (i.e. a proc or lambda etc..) # which must return a hash of headers to add + # @!attribute [rw] shift_api_key + # Used to authenticate to the API + # Defaults to nil + # @!attribute [rw] shift_account_reference + # Specifies which account to use when authenticating with the API + # Defaults to nil + # @!attribute [rw] oauth2_server_url + # The base URL for the authentication server + # Defaults to nil + # @!attribute [rw] oauth2_client_id + # The client id for use in oauth2 auth + # @!attribute [rw] oauth2_client_secret + # The client secret for use in oauth2 auth # @!attribute [rw] timeout # The connection read timeout in seconds. If data is not received in # this time, an error is raised. @@ -62,7 +75,7 @@ class Config # The connection open timeout in seconds - i.e. if it takes longer than # this to open connection, an error is raised # defaults to :default (15 seconds) - attr_reader :shift_root_url, :logger, :before_request_handlers, :after_response_handlers, :adapter, :headers, :timeout, :open_timeout + attr_reader :shift_root_url, :logger, :before_request_handlers, :after_response_handlers, :adapter, :headers, :timeout, :open_timeout, :shift_api_key, :shift_account_reference, :oauth2_server_url, :oauth2_client_id, :oauth2_client_secret def initialize @before_request_handlers = [] @@ -99,6 +112,26 @@ def open_timeout=(open_timeout) @open_timeout = open_timeout.tap { reconfigure } end + def shift_api_key=(api_key) + @shift_api_key = api_key.tap { reconfigure } + end + + def shift_account_reference=(account_reference) + @shift_account_reference = account_reference.tap { reconfigure } + end + + def oauth2_server_url=(url) + @oauth2_server_url = url.tap { reconfigure } + end + + def oauth2_client_id=(id) + @oauth2_client_id = id.tap { reconfigure } + end + + def oauth2_client_secret=(secret) + @oauth2_client_secret = secret.tap { reconfigure } + end + # Registers a handler that is to be called before the request is made # to the server. # Multiple handlers get called in the sequence they were registered diff --git a/lib/shift/api/core/middleware/oauth2_token_exchanger.rb b/lib/shift/api/core/middleware/oauth2_token_exchanger.rb new file mode 100644 index 0000000..74b2d8a --- /dev/null +++ b/lib/shift/api/core/middleware/oauth2_token_exchanger.rb @@ -0,0 +1,50 @@ +module Shift + module Api + module Core + module Middleware + # + # Faraday middleware to exchange an api key for an oauth2 token + # and store this in the request headers + # + class Oauth2TokenExchanger < ::Faraday::Middleware + def initialize(app, api_key:, account_reference:, oauth_server_url:, client_id:, client_secret:, token_create_service: ::Shift::Api::Core::CreateTokenFromApiKey) + self.app = app + self.api_key = api_key + self.account_reference = account_reference + self.oauth_server_url = oauth_server_url + self.token_create_service = token_create_service + self.client_id = client_id + self.client_secret = client_secret + end + + # Fetches new token if required then call app + # @param [Faraday::Env] env The environment from faraday + def call(env) + return app.call(env) if has_valid_token?(env) + fetch_token(env) + app.call(env) + end + + private + + def has_valid_token?(env) + env.request_headers.key?('Authorization') + end + + def connection + @connection ||= connection_class.new(site: oauth_server_url) + + end + + def fetch_token(env) + token = token_create_service.call client_id: client_id, scope: "all", api_key: api_key + #response = connection.run :post, "/oauth2/application_token", client_id: client_id, client_secret: client_secret, scope: "all", api_key: api_key + env.request_headers.merge! "Authorization" => "Bearer #{token.access_token}" + end + + attr_accessor :app, :api_key, :account_reference, :oauth_server_url, :token_create_service, :client_id, :client_secret + end + end + end + end +end diff --git a/lib/shift/api/core/model.rb b/lib/shift/api/core/model.rb index 3cac390..2844a0a 100644 --- a/lib/shift/api/core/model.rb +++ b/lib/shift/api/core/model.rb @@ -3,6 +3,7 @@ require "shift/api/core/middleware/custom_headers" require "shift/api/core/middleware/inspector" require "shift/api/core/middleware/error_handler" +require "shift/api/core/middleware/oauth2_token_exchanger" module Shift module Api module Core @@ -25,6 +26,7 @@ def self.reconfigure(config) configure_timeout(config) configure_open_timeout(config) configure_headers(config) + configure_token_exchanger(config) reconfigure_subclasses(config) end @@ -49,7 +51,7 @@ def self.configure_adapter(config) def self.configure_headers(config) headers = config.headers - connection.use(::Shift::Api::Core::Middleware::CustomHeaders, headers: headers) unless headers.empty? + connection.use(::Shift::Api::Core::Middleware::CustomHeaders, headers: headers) end def self.configure_logger(config) @@ -78,6 +80,17 @@ def self.configure_open_timeout(config) connection.faraday.options.merge!(open_timeout: open_timeout.to_i) unless open_timeout == "default" end + def self.configure_token_exchanger(config) + return if config.shift_api_key.nil? + connection.faraday.builder.insert_after(::Shift::Api::Core::Middleware::CustomHeaders, + ::Shift::Api::Core::Middleware::Oauth2TokenExchanger, + api_key: config.shift_api_key, + account_reference: config.shift_account_reference, + oauth_server_url: config.oauth2_server_url, + client_id: config.oauth2_client_id, + client_secret: config.oauth2_client_secret) + end + def self.reconfigure_subclasses(config) subclasses.each do |klass| klass.reconfigure(config) diff --git a/lib/shift/api/core/models/create_token_from_api_key.rb b/lib/shift/api/core/models/create_token_from_api_key.rb new file mode 100644 index 0000000..3d7bdc5 --- /dev/null +++ b/lib/shift/api/core/models/create_token_from_api_key.rb @@ -0,0 +1,23 @@ +module Shift + module Api + module Core + class CreateTokenFromApiKey < Model + def self.configure_token_exchanger(config) + # Noop - this prevents this special model from using the token exchanger + end + + def self.site=(url) + super(url.nil? ? url : url.gsub(/\/[^\/]*\/v1/, "")) + end + + def self.table_name + "oauth2/application_token" + end + + def self.call(attrs) + create(attrs) + end + end + end + end +end diff --git a/shift-api-core.gemspec b/shift-api-core.gemspec index 4ccfa77..13e3611 100644 --- a/shift-api-core.gemspec +++ b/shift-api-core.gemspec @@ -28,4 +28,5 @@ Gem::Specification.new do |spec| spec.add_development_dependency 'byebug' spec.add_development_dependency 'webmock', '~> 2.3' spec.add_runtime_dependency 'json_api_client', '~> 1.5' + spec.add_runtime_dependency "jwt-bouncer", "~> 0.1" end diff --git a/spec/integration/token_exchange_spec.rb b/spec/integration/token_exchange_spec.rb new file mode 100644 index 0000000..e396fdd --- /dev/null +++ b/spec/integration/token_exchange_spec.rb @@ -0,0 +1,49 @@ +require "spec_helper" +# +# Token Exchange Integration Spec +# +# The purpose of this spec is to prove that the gem will exchange an api key for a token +# when required before a request is made to the protected resources +RSpec.describe "token exchange integration", type: :api do + let(:account_reference) { SecureRandom.uuid } + let(:api_key) { SecureRandom.hex(16) } + let(:token_exchange_url) { "http://test.com/oauth2/application_token" } + before(:each) do + Shift::Api::Core::Config.new.batch_configure do |config| + config.shift_root_url = "http://test.com/anyservice/v1" + config.shift_api_key = api_key + config.shift_account_reference = account_reference + config.oauth2_server_url = token_exchange_url + end + end + + before(:each) { stub_request(:post, "http://test.com/anyservice/v1/users").to_return(resource_stub_response) } + + let(:resource_stub_response) do + { + body: {data: [{id: "1", type: "users", attributes: {name: "Shift User"}}]}.to_json, + status: 200, + headers: { "Content-Type": "application/vnd.api+json" } + } + end + + let(:token_exchange_response) do + { + body: {data: {attributes: {token_type: "Bearer", expires_in: 60, access_token: "aaa"}}}.to_json, + status: 201, + headers: { "Content-Type": "application/vnd.api+json" } + } + end + + + class User < Shift::Api::Core::Model + + end + + it "should exchange the api key for a token and then access the resource" do + token_exchange_stub = stub_request(:post, token_exchange_url).to_return token_exchange_response + user = User.create(name: "Shift User") + expect(a_request(:post, token_exchange_url).with(headers: {"Content-Type" => "application/vnd.api+json", "Accept" => "application/vnd.api+json"})).to have_been_made + expect(a_request(:post, "http://test.com/anyservice/v1/users").with(headers: {"Authorization": "Bearer aaa"})).to have_been_made + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 832d03b..e9043fe 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -9,3 +9,4 @@ c.syntax = :expect end end +Dir[File.expand_path('../support/**/*.rb', __FILE__)].each { |f| require f } diff --git a/spec/support/jwt_bouncer_helpers.rb b/spec/support/jwt_bouncer_helpers.rb new file mode 100644 index 0000000..54a6d87 --- /dev/null +++ b/spec/support/jwt_bouncer_helpers.rb @@ -0,0 +1,56 @@ +require 'jwt_bouncer/sign_request' + +module JwtBouncerHelpers + + def self.included(spec) + spec.after do + clear_authorization_header + end + end + + def clear_authorization_header + set_jwt_bouncer_shared_secret + + Shift::Api::Core.config do |config| + config.headers = { } + end + end + + def generate_authorization_token(account_reference:, permissions:, shared_secret:, expiry: nil, actor: { type: 'user', id: 1, name: 'Jenkins' }) + JwtBouncer::SignRequest.generate_token( + permissions: permissions, + actor: actor, + account_reference: account_reference, + shared_secret: shared_secret, + expiry: expiry + ) + end + + def decode_jwt_token(token, shared_secret:) + decoded = JwtBouncer::Token.decode(token, shared_secret) + decoded['permissions'] = JwtBouncer::Permissions.decompress(decoded['permissions']) + decoded['actor'] = decoded['actor'].with_indifferent_access + OpenStruct.new(decoded).freeze + end + + def set_authorization_header(**options) + clear_authorization_header + + token = generate_authorization_token(**options) + + Shift::Api::Core.config do |config| + config.headers = { 'Authorization' => "Bearer #{token}" } + end + end + + def set_jwt_bouncer_shared_secret + ENV['JWT_BOUNCER_SHARED_SECRET'] ||= 'Some shared secret' + end + +end + +RSpec.configure do |config| + + config.include JwtBouncerHelpers + +end diff --git a/spec/unit/shift/api/core/config_spec.rb b/spec/unit/shift/api/core/config_spec.rb index 89e3b70..eea1ac8 100644 --- a/spec/unit/shift/api/core/config_spec.rb +++ b/spec/unit/shift/api/core/config_spec.rb @@ -76,9 +76,92 @@ config_instance.headers = headers headers.merge!(unwanted: :key) expect(subject.headers).to eql(key: :value) + end + end + + describe "shift_api_key=" do + it "should have a default value of nil" do + expect(config_instance.shift_api_key).to be_nil + end + + it "should request a reconfigure" do + config_instance.shift_api_key = SecureRandom.hex(16) + expect(mock_base_model).to have_received(:reconfigure).with(subject) + end + + it "should store the value set" do + key = SecureRandom.hex(16) + config_instance.shift_api_key = key + expect(config_instance.shift_api_key).to eql key + end + end + describe "shift_account_reference=" do + it "should have a default value of nil" do + expect(config_instance.shift_account_reference).to be_nil end + it "should request a reconfigure" do + config_instance.shift_account_reference = SecureRandom.uuid + expect(mock_base_model).to have_received(:reconfigure).with(subject) + end + + it "should store the value set" do + account_reference = SecureRandom.uuid + config_instance.shift_account_reference = account_reference + expect(config_instance.shift_account_reference).to eql account_reference + end + end + + describe "oauth2_server_url=" do + it "should have a default value of nil" do + expect(config_instance.oauth2_server_url).to be_nil + end + + it "should request a reconfigure" do + config_instance.oauth2_server_url = "http://test.com" + expect(mock_base_model).to have_received(:reconfigure).with(subject) + end + + it "should store the value set" do + url = "http://test.com" + config_instance.oauth2_server_url = url + expect(config_instance.oauth2_server_url).to eql url + end + end + + describe "oauth2_client_id=" do + it "should have a default value of nil" do + expect(config_instance.oauth2_client_id).to be_nil + end + + it "should request a reconfigure" do + config_instance.oauth2_client_id = "clientid" + expect(mock_base_model).to have_received(:reconfigure).with(subject) + end + + it "should store the value set" do + client_id = "clientid" + config_instance.oauth2_client_id = client_id + expect(config_instance.oauth2_client_id).to eql client_id + end + end + + describe "oauth2_client_secret=" do + it "should have a default value of nil" do + expect(config_instance.oauth2_client_secret).to be_nil + end + + it "should request a reconfigure" do + config_instance.oauth2_client_secret = "secret" + expect(mock_base_model).to have_received(:reconfigure).with(subject) + end + + it "should store the value set" do + client_secret = "secret" + config_instance.oauth2_client_secret = client_secret + expect(config_instance.oauth2_client_secret).to eql client_secret + end end describe "#before_request" do diff --git a/spec/unit/shift/api/core/middleware/oauth2_token_exchanger_spec.rb b/spec/unit/shift/api/core/middleware/oauth2_token_exchanger_spec.rb new file mode 100644 index 0000000..37565fb --- /dev/null +++ b/spec/unit/shift/api/core/middleware/oauth2_token_exchanger_spec.rb @@ -0,0 +1,54 @@ +require "spec_helper" +require "shift/api/core/middleware/oauth2_token_exchanger" +module Shift + module Api + module Core + module Middleware + RSpec.describe Oauth2TokenExchanger do + # A shared secret for the JWT tokens + let(:shared_secret) { SecureRandom.uuid } + # A Mock app from faradays perspective - similar to a rack app + let(:mock_app) { instance_spy("Application") } + # An api key - used throughout + let(:api_key) { SecureRandom.hex(16) } + # The account reference - used throughout + let(:account_reference) { SecureRandom.uuid } + # The token exchange url - used throughout and its value is not important to this test + let(:token_exchange_url) { "http://test.com/oauth2/application_token" } + let(:client_id) { SecureRandom.uuid } + let(:client_secret) { SecureRandom.uuid } + # Mock connection + let(:mock_create_token_service) { class_double(::Shift::Api::Core::CreateTokenFromApiKey) } + + subject(:token_exchanger_instance) { Oauth2TokenExchanger.new(mock_app, api_key: api_key, account_reference: account_reference, oauth_server_url: token_exchange_url, client_id: client_id, client_secret: client_secret, token_create_service: mock_create_token_service) } + + it "should pass through if the headers already contain a token which has not expired" do + token = generate_authorization_token(account_reference: account_reference, permissions: {}, shared_secret: shared_secret) + mock_headers = {"Authorization" => token}.freeze + env = instance_double("Faraday::Env", method: :get, url: "http://test.com/v1/users", request_headers: mock_headers) + token_exchanger_instance.call(env) + expect(mock_app).to have_received(:call).with(env) + expect(env.request_headers).to include("Authorization" => token) + end + + it "should request a new token from scratch and call the app if we dont have a token at all" do + access_token = "anytokendoesntmatter" + mock_response = double(::Shift::Api::Core::CreateTokenFromApiKey, access_token: access_token, token_type: "Bearer", expires_in: 60) + expect(mock_create_token_service).to receive(:call).with(client_id: client_id, client_secret: client_secret, scope: "all", api_key: api_key).and_return mock_response + mock_headers = {} + env = instance_double("Faraday::Env", method: :get, url: "http://test.com/v1/users", request_headers: mock_headers) + token_exchanger_instance.call(env) + expect(mock_app).to have_received(:call).with(env) + expect(env.request_headers).to include("Authorization" => "Bearer #{access_token}") + end + + it "should request a new token from the refresh token and call the app if we have a token that is close to expiry" + + it "should request a new token from the refresh token and call the app if we have a token that has expired" + + end + end + end + end +end + diff --git a/spec/unit/shift/api/core/model_spec.rb b/spec/unit/shift/api/core/model_spec.rb index 275f0fa..850b4a0 100644 --- a/spec/unit/shift/api/core/model_spec.rb +++ b/spec/unit/shift/api/core/model_spec.rb @@ -119,14 +119,14 @@ expect(mock_custom_headers_middleware).to have_received(:new).with(mock_app, headers: mock_headers) end - it "should not add the custom headers middleware if headers are empty in the config" do + it "should add the custom headers middleware even if headers are empty in the config" do allow(config).to receive(:headers).and_return({}) Shift::Api::Core::Model.reconfigure(config) connection = Shift::Api::Core::Model.connection - expect(connection.faraday.builder.handlers).not_to include mock_custom_headers_middleware + expect(connection.faraday.builder.handlers).to include mock_custom_headers_middleware end - it "should remove the custom headers middleware if headers get set to an empty hash in the config" do + it "should not remove the custom headers middleware if headers get set to an empty hash in the config" do allow(config).to receive(:headers).and_return(mock_headers) Shift::Api::Core::Model.reconfigure(config) # Load the connection so it is cached @@ -134,7 +134,7 @@ allow(config).to receive(:headers).and_return({}) Shift::Api::Core::Model.reconfigure(config) connection = Shift::Api::Core::Model.connection - expect(connection.faraday.builder.handlers).not_to include mock_custom_headers_middleware + expect(connection.faraday.builder.handlers).to include mock_custom_headers_middleware end end @@ -234,6 +234,20 @@ end end + context "token exchanger middleware" do + let!(:mock_token_exchanger_middleware) { class_spy(Shift::Api::Core::Middleware::Oauth2TokenExchanger).as_stubbed_const } + it "should be inserted into the middleware before the custom config middleware" do + config.shift_api_key="anykey" + Shift::Api::Core::Model.reconfigure(config) + connection = Shift::Api::Core::Model.connection + handlers = connection.faraday.builder.handlers + expect(handlers).to include mock_token_exchanger_middleware + token_exchanger_middleware_idx = handlers.index(mock_token_exchanger_middleware) + config_middleware_idx = handlers.index(Shift::Api::Core::Middleware::CustomHeaders) + expect(token_exchanger_middleware_idx).to be > config_middleware_idx + end + end + context "reconfigure" do it "should inform all subclasses to reconfigure!" do allow(mock_model_1).to receive(:reconfigure)