From f5feadc81fa85a829b9996e8ff7e212f1467b901 Mon Sep 17 00:00:00 2001 From: Paul Thornthwaite Date: Wed, 21 Nov 2012 12:11:26 +0000 Subject: [PATCH 01/10] [Brightbox] Refactors credential code in Compute --- lib/fog/brightbox/compute.rb | 30 +++++++++++++++++----------- lib/fog/brightbox/oauth2.rb | 35 +++++++++++++++++++++++++++++++++ tests/brightbox/oauth2_tests.rb | 20 +++++++++++++++++++ 3 files changed, 73 insertions(+), 12 deletions(-) create mode 100644 lib/fog/brightbox/oauth2.rb create mode 100644 tests/brightbox/oauth2_tests.rb diff --git a/lib/fog/brightbox/compute.rb b/lib/fog/brightbox/compute.rb index 4114a2368..91d54d494 100644 --- a/lib/fog/brightbox/compute.rb +++ b/lib/fog/brightbox/compute.rb @@ -1,5 +1,6 @@ require 'fog/brightbox' require 'fog/compute' +require 'fog/brightbox/oauth2' module Fog module Compute @@ -162,6 +163,7 @@ module Fog class Real include Shared + include Fog::Brightbox::OAuth2 # Creates a new instance of the Brightbox Compute service # @@ -185,12 +187,15 @@ module Fog @connection = Fog::Connection.new(@api_url, @persistent, @connection_options) # Authentication options - @brightbox_client_id = options[:brightbox_client_id] || Fog.credentials[:brightbox_client_id] - @brightbox_secret = options[:brightbox_secret] || Fog.credentials[:brightbox_secret] + client_id = options[:brightbox_client_id] || Fog.credentials[:brightbox_client_id] + client_secret = options[:brightbox_secret] || Fog.credentials[:brightbox_secret] - @brightbox_username = options[:brightbox_username] || Fog.credentials[:brightbox_username] - @brightbox_password = options[:brightbox_password] || Fog.credentials[:brightbox_password] - @brightbox_account = options[:brightbox_account] || Fog.credentials[:brightbox_account] + username = options[:brightbox_username] || Fog.credentials[:brightbox_username] + password = options[:brightbox_password] || Fog.credentials[:brightbox_password] + @scoped_account = options[:brightbox_account] || Fog.credentials[:brightbox_account] + + credential_options = {:username => username, :password => password} + @credentials = CredentialSet.new(client_id, client_secret, credential_options) end # Makes an API request to the given path using passed options or those @@ -214,7 +219,7 @@ module Fog :path => path, :expects => expected_responses } - parameters[:account_id] = @brightbox_account if parameters[:account_id].nil? && @brightbox_account + parameters[:account_id] = @scoped_account if parameters[:account_id].nil? && @scoped_account request_options[:body] = Fog::JSON.encode(parameters) unless parameters.empty? make_request(request_options) end @@ -237,8 +242,9 @@ module Fog # Returns true if authentication is being performed as a user # @return [Boolean] def authenticating_as_user? - @brightbox_username && @brightbox_password + @credentials.user_details? end + private def get_oauth_token(options = {}) auth_url = options[:brightbox_auth_url] || @auth_url @@ -246,13 +252,13 @@ module Fog connection = Fog::Connection.new(auth_url) authentication_body_hash = if authenticating_as_user? { - 'client_id' => @brightbox_client_id, + 'client_id' => @credentials.client_id, 'grant_type' => 'password', - 'username' => @brightbox_username, - 'password' => @brightbox_password + 'username' => @credentials.username, + 'password' => @credentials.password } else - {'client_id' => @brightbox_client_id, 'grant_type' => 'none'} + {'client_id' => @credentials.client_id, 'grant_type' => 'none'} end @authentication_body = Fog::JSON.encode(authentication_body_hash) @@ -260,7 +266,7 @@ module Fog :path => "/token", :expects => 200, :headers => { - 'Authorization' => "Basic " + Base64.encode64("#{@brightbox_client_id}:#{@brightbox_secret}").chomp, + 'Authorization' => "Basic " + Base64.encode64("#{@credentials.client_id}:#{@credentials.client_secret}").chomp, 'Content-Type' => 'application/json' }, :method => 'POST', diff --git a/lib/fog/brightbox/oauth2.rb b/lib/fog/brightbox/oauth2.rb new file mode 100644 index 000000000..549d63207 --- /dev/null +++ b/lib/fog/brightbox/oauth2.rb @@ -0,0 +1,35 @@ +# This module covers Brightbox's partial implementation of OAuth 2.0 +# and enables fog clients to implement several authentictication strategies +# +# @see http://tools.ietf.org/html/draft-ietf-oauth-v2-10 +# +module Fog::Brightbox::OAuth2 + + # Encapsulates credentials required to request access tokens from the + # Brightbox authorisation servers + # + # @todo Interface to update certain credentials (after password change) + # + class CredentialSet + attr_reader :client_id, :client_secret, :username, :password + # + # @param [String] client_id + # @param [String] client_secret + # @param [Hash] options + # @option options [String] :username + # @option options [String] :password + # + def initialize(client_id, client_secret, options = {}) + @client_id = client_id + @client_secret = client_secret + @username = options[:username] + @password = options[:password] + end + + # Returns true if user details are available + # @return [Boolean] + def user_details? + !!(@username && @password) + end + end +end diff --git a/tests/brightbox/oauth2_tests.rb b/tests/brightbox/oauth2_tests.rb new file mode 100644 index 000000000..52639d84d --- /dev/null +++ b/tests/brightbox/oauth2_tests.rb @@ -0,0 +1,20 @@ +Shindo.tests("Fog::Brightbox::OAuth2", ["brightbox"]) do + + tests("CredentialSet") do + @client_id = "app-12345" + @client_secret = "__mashed_keys_123__" + @username = "usr-12345" + @password = "__mushed_keys_321__" + + tests("with client credentials") do + credentials = Fog::Brightbox::OAuth2::CredentialSet.new(@client_id, @client_secret) + tests("#user_details?").returns(false) { credentials.user_details? } + end + + tests("with user credentials") do + options = {:username => @username, :password => @password} + credentials = Fog::Brightbox::OAuth2::CredentialSet.new(@client_id, @client_secret, options) + tests("#user_details?").returns(true) { credentials.user_details? } + end + end +end From c14c6ad76fd03a09f977a8de237dc4c27eb26eee Mon Sep 17 00:00:00 2001 From: Paul Thornthwaite Date: Wed, 21 Nov 2012 14:54:41 +0000 Subject: [PATCH 02/10] [Brightbox] Extracts authentication connection Moving the URL for the authentication endpoint up to the instance level rather than hiding the logic in the method. Eliminate the need for options on a private method. Also cleaned up references to credentials in same method. --- lib/fog/brightbox/compute.rb | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/lib/fog/brightbox/compute.rb b/lib/fog/brightbox/compute.rb index 91d54d494..621209afa 100644 --- a/lib/fog/brightbox/compute.rb +++ b/lib/fog/brightbox/compute.rb @@ -181,6 +181,8 @@ module Fog def initialize(options) # Currently authentication and api endpoints are the same but may change @auth_url = options[:brightbox_auth_url] || Fog.credentials[:brightbox_auth_url] || API_URL + @auth_connection = Fog::Connection.new(@auth_url) + @api_url = options[:brightbox_api_url] || Fog.credentials[:brightbox_api_url] || API_URL @connection_options = options[:connection_options] || {} @persistent = options[:persistent] || false @@ -246,10 +248,8 @@ module Fog end private - def get_oauth_token(options = {}) - auth_url = options[:brightbox_auth_url] || @auth_url - connection = Fog::Connection.new(auth_url) + def get_oauth_token authentication_body_hash = if authenticating_as_user? { 'client_id' => @credentials.client_id, @@ -262,11 +262,13 @@ module Fog end @authentication_body = Fog::JSON.encode(authentication_body_hash) - response = connection.request({ + basic_header_to_encode = "#{@credentials.client_id}:#{@credentials.client_secret}" + + response = @auth_connection.request({ :path => "/token", :expects => 200, :headers => { - 'Authorization' => "Basic " + Base64.encode64("#{@credentials.client_id}:#{@credentials.client_secret}").chomp, + 'Authorization' => "Basic " + Base64.encode64(basic_header_to_encode).chomp, 'Content-Type' => 'application/json' }, :method => 'POST', From 62cddead5ca1ab3a9bbeb92a818207cfdad1e503 Mon Sep 17 00:00:00 2001 From: Paul Thornthwaite Date: Wed, 21 Nov 2012 13:31:43 +0000 Subject: [PATCH 03/10] [Brightbox] Refactors how tokens are requested Extracting existing strategies out of Compute --- lib/fog/brightbox/compute.rb | 14 +++------- lib/fog/brightbox/oauth2.rb | 46 +++++++++++++++++++++++++++++++++ tests/brightbox/oauth2_tests.rb | 42 ++++++++++++++++++++++++++++++ 3 files changed, 92 insertions(+), 10 deletions(-) diff --git a/lib/fog/brightbox/compute.rb b/lib/fog/brightbox/compute.rb index 621209afa..be5cb3f68 100644 --- a/lib/fog/brightbox/compute.rb +++ b/lib/fog/brightbox/compute.rb @@ -250,17 +250,11 @@ module Fog private def get_oauth_token - authentication_body_hash = if authenticating_as_user? - { - 'client_id' => @credentials.client_id, - 'grant_type' => 'password', - 'username' => @credentials.username, - 'password' => @credentials.password - } + if authenticating_as_user? + token_strategy = UserCredentialsStrategy.new(@credentials) else - {'client_id' => @credentials.client_id, 'grant_type' => 'none'} + token_strategy = ClientCredentialsStrategy.new(@credentials) end - @authentication_body = Fog::JSON.encode(authentication_body_hash) basic_header_to_encode = "#{@credentials.client_id}:#{@credentials.client_secret}" @@ -272,7 +266,7 @@ module Fog 'Content-Type' => 'application/json' }, :method => 'POST', - :body => @authentication_body + :body => Fog::JSON.encode(token_strategy.authorization_body_data) }) @oauth_token = Fog::JSON.decode(response.body)["access_token"] return @oauth_token diff --git a/lib/fog/brightbox/oauth2.rb b/lib/fog/brightbox/oauth2.rb index 549d63207..c8f32f0dc 100644 --- a/lib/fog/brightbox/oauth2.rb +++ b/lib/fog/brightbox/oauth2.rb @@ -32,4 +32,50 @@ module Fog::Brightbox::OAuth2 !!(@username && @password) end end + + # This strategy class is the basis for OAuth2 grant types + # + # @abstract Need to implement {#authorization_body_data} to return a + # Hash matching the expected parameter form for the OAuth request + # + # @todo Strategies should be able to validate if credentials are suitable + # so just client credentials cannot be used with user strategies + # + class GrantTypeStrategy + def initialize(credentials) + @credentials = credentials + end + + def authorization_body_data + raise "Not implemented" + end + end + + # This implements client based authentication/authorization + # based on the existing trust relationship using the `none` + # grant type. + # + class ClientCredentialsStrategy < GrantTypeStrategy + def authorization_body_data + { + "grant_type" => "none", + "client_id" => @credentials.client_id + } + end + end + + # This passes user details through so the returned token + # carries the privileges of the user not account limited + # by the client + # + class UserCredentialsStrategy < GrantTypeStrategy + def authorization_body_data + { + "grant_type" => "password", + "client_id" => @credentials.client_id, + "username" => @credentials.username, + "password" => @credentials.password + } + end + end end diff --git a/tests/brightbox/oauth2_tests.rb b/tests/brightbox/oauth2_tests.rb index 52639d84d..969b81264 100644 --- a/tests/brightbox/oauth2_tests.rb +++ b/tests/brightbox/oauth2_tests.rb @@ -17,4 +17,46 @@ Shindo.tests("Fog::Brightbox::OAuth2", ["brightbox"]) do tests("#user_details?").returns(true) { credentials.user_details? } end end + + tests("GrantTypeStrategy") do + credentials = Fog::Brightbox::OAuth2::CredentialSet.new(@client_id, @client_secret) + strategy = Fog::Brightbox::OAuth2::GrantTypeStrategy.new(credentials) + + tests("#respond_to? :authorization_body_data").returns(true) do + strategy.respond_to?(:authorization_body_data) + end + end + + tests("ClientCredentialsStrategy") do + credentials = Fog::Brightbox::OAuth2::CredentialSet.new(@client_id, @client_secret) + strategy = Fog::Brightbox::OAuth2::ClientCredentialsStrategy.new(credentials) + + tests("#respond_to? :authorization_body_data").returns(true) do + strategy.respond_to?(:authorization_body_data) + end + + tests("#authorization_body_data") do + authorization_body_data = strategy.authorization_body_data + test("grant_type == none") { authorization_body_data["grant_type"] == "none" } + test("client_id == #{@client_id}") { authorization_body_data["client_id"] == @client_id } + end + end + + tests("UserCredentialsStrategy") do + options = {:username => @username, :password => @password} + credentials = Fog::Brightbox::OAuth2::CredentialSet.new(@client_id, @client_secret, options) + strategy = Fog::Brightbox::OAuth2::UserCredentialsStrategy.new(credentials) + + tests("#respond_to? :authorization_body_data").returns(true) do + strategy.respond_to?(:authorization_body_data) + end + + tests("#authorization_body_data") do + authorization_body_data = strategy.authorization_body_data + test("grant_type == password") { authorization_body_data["grant_type"] == "password" } + test("client_id == #{@client_id}") { authorization_body_data["client_id"] == @client_id } + test("username == #{@username}") { authorization_body_data["username"] == @username } + test("password == #{@password}") { authorization_body_data["password"] == @password } + end + end end From a1f1a8b8ce8ea984af21131f45d87d48d674ccf4 Mon Sep 17 00:00:00 2001 From: Paul Thornthwaite Date: Wed, 21 Nov 2012 14:31:04 +0000 Subject: [PATCH 04/10] [Brightbox] Moves tokens to CredentialSet --- lib/fog/brightbox/compute.rb | 38 ++++++++++++++++++++++++++++---- lib/fog/brightbox/oauth2.rb | 19 ++++++++++++++++ tests/brightbox/compute_tests.rb | 4 +++- tests/brightbox/oauth2_tests.rb | 14 ++++++++++++ 4 files changed, 70 insertions(+), 5 deletions(-) diff --git a/lib/fog/brightbox/compute.rb b/lib/fog/brightbox/compute.rb index be5cb3f68..fc96dea74 100644 --- a/lib/fog/brightbox/compute.rb +++ b/lib/fog/brightbox/compute.rb @@ -17,6 +17,9 @@ module Fog # User credentials (still requires client details) recognizes :brightbox_username, :brightbox_password, :brightbox_account + # Cached tokens + recognizes :brightbox_access_token, :brightbox_refresh_token + # Excon connection settings recognizes :persistent @@ -167,6 +170,9 @@ module Fog # Creates a new instance of the Brightbox Compute service # + # @note If you open a connection using just a refresh token when it + # expires the service will no longer be able to authenticate. + # # @param [Hash] options # @option options [String] :brightbox_api_url Override the default (or configured) API endpoint # @option options [String] :brightbox_auth_url Override the default (or configured) API authentication endpoint @@ -177,6 +183,8 @@ module Fog # @option options [String] :brightbox_account Account identifier to scope this connection to # @option options [String] :connection_options Settings to pass to underlying {Fog::Connection} # @option options [Boolean] :persistent Sets a persistent HTTP {Fog::Connection} + # @option options [String] :brightbox_access_token Sets the OAuth access token to use rather than requesting a new token + # @option options [String] :brightbox_refresh_token Sets the refresh token to use when requesting a newer access token # def initialize(options) # Currently authentication and api endpoints are the same but may change @@ -198,6 +206,9 @@ module Fog credential_options = {:username => username, :password => password} @credentials = CredentialSet.new(client_id, client_secret, credential_options) + + # If existing tokens have been cached, allow continued use of them in the service + @credentials.update_tokens(options[:brightbox_access_token], options[:brightbox_refresh_token]) end # Makes an API request to the given path using passed options or those @@ -247,6 +258,24 @@ module Fog @credentials.user_details? end + # Returns true if an access token is set + # @return [Boolean] + def access_token_available? + !! @credentials.access_token + end + + # Returns the current access token or nil + # @return [String,nil] + def access_token + @credentials.access_token + end + + # Returns the current refresh token or nil + # @return [String,nil] + def refresh_token + @credentials.refresh_token + end + private def get_oauth_token @@ -268,13 +297,14 @@ module Fog :method => 'POST', :body => Fog::JSON.encode(token_strategy.authorization_body_data) }) - @oauth_token = Fog::JSON.decode(response.body)["access_token"] - return @oauth_token + response_data = Fog::JSON.decode(response.body) + @credentials.update_tokens(response_data["access_token"], response_data["refresh_token"]) + @credentials.access_token end def make_request(params) begin - get_oauth_token if @oauth_token.nil? + get_oauth_token unless access_token_available? response = authenticated_request(params) rescue Excon::Errors::Unauthorized get_oauth_token @@ -287,7 +317,7 @@ module Fog def authenticated_request(options) headers = options[:headers] || {} - headers.merge!("Authorization" => "OAuth #{@oauth_token}", "Content-Type" => "application/json") + headers.merge!("Authorization" => "OAuth #{@credentials.access_token}", "Content-Type" => "application/json") options[:headers] = headers @connection.request(options) end diff --git a/lib/fog/brightbox/oauth2.rb b/lib/fog/brightbox/oauth2.rb index c8f32f0dc..530cf4d83 100644 --- a/lib/fog/brightbox/oauth2.rb +++ b/lib/fog/brightbox/oauth2.rb @@ -12,6 +12,7 @@ module Fog::Brightbox::OAuth2 # class CredentialSet attr_reader :client_id, :client_secret, :username, :password + attr_reader :access_token, :refresh_token # # @param [String] client_id # @param [String] client_secret @@ -24,6 +25,8 @@ module Fog::Brightbox::OAuth2 @client_secret = client_secret @username = options[:username] @password = options[:password] + @access_token = options[:access_token] + @refresh_token = options[:refresh_token] end # Returns true if user details are available @@ -31,6 +34,22 @@ module Fog::Brightbox::OAuth2 def user_details? !!(@username && @password) end + + # Is an access token available for these credentials? + def access_token? + !!@access_token + end + + # Is a refresh token available for these credentials? + def refresh_token? + !!@refresh_token + end + + # Updates the credentials with newer tokens + def update_tokens(access_token, refresh_token = nil) + @access_token = access_token + @refresh_token = refresh_token + end end # This strategy class is the basis for OAuth2 grant types diff --git a/tests/brightbox/compute_tests.rb b/tests/brightbox/compute_tests.rb index f3a15134a..a3f043895 100644 --- a/tests/brightbox/compute_tests.rb +++ b/tests/brightbox/compute_tests.rb @@ -18,7 +18,9 @@ Shindo.tests('Fog::Compute.new', ['brightbox']) do :brightbox_secret => "12345abdef6789", :brightbox_username => "user-12345", :brightbox_password => "password1234", - :brightbox_account => "acc-12345" + :brightbox_account => "acc-12345", + :brightbox_access_token => "12345abdef6789", + :brightbox_refresh_token => "12345abdef6789" }.each_pair do |option, sample| tests("recognises :#{option}").returns(true) do options = {:provider => "Brightbox"} diff --git a/tests/brightbox/oauth2_tests.rb b/tests/brightbox/oauth2_tests.rb index 969b81264..46fb82835 100644 --- a/tests/brightbox/oauth2_tests.rb +++ b/tests/brightbox/oauth2_tests.rb @@ -5,16 +5,30 @@ Shindo.tests("Fog::Brightbox::OAuth2", ["brightbox"]) do @client_secret = "__mashed_keys_123__" @username = "usr-12345" @password = "__mushed_keys_321__" + @access_token = "12efde32fdfe4989" + @refresh_token = "7894389f9074f071" tests("with client credentials") do credentials = Fog::Brightbox::OAuth2::CredentialSet.new(@client_id, @client_secret) tests("#user_details?").returns(false) { credentials.user_details? } + tests("#access_token?").returns(false) { credentials.access_token? } + tests("#refresh_token?").returns(false) { credentials.refresh_token? } end tests("with user credentials") do options = {:username => @username, :password => @password} credentials = Fog::Brightbox::OAuth2::CredentialSet.new(@client_id, @client_secret, options) tests("#user_details?").returns(true) { credentials.user_details? } + tests("#access_token?").returns(false) { credentials.access_token? } + tests("#refresh_token?").returns(false) { credentials.refresh_token? } + end + + tests("with existing tokens") do + options = {:username => @username, :access_token => @access_token, :refresh_token => @refresh_token} + credentials = Fog::Brightbox::OAuth2::CredentialSet.new(@client_id, @client_secret, options) + tests("#user_details?").returns(false) { credentials.user_details? } + tests("#access_token?").returns(true) { credentials.access_token? } + tests("#refresh_token?").returns(true) { credentials.refresh_token? } end end From d833c831694bee85aab48393998ea76485e4ee39 Mon Sep 17 00:00:00 2001 From: Paul Thornthwaite Date: Wed, 21 Nov 2012 18:05:41 +0000 Subject: [PATCH 05/10] [Brightbox] Extracts parts of request out of compute * Adds simple helper to get token for service * Low level request #request_access_token is provided to request access tokens --- lib/fog/brightbox/compute.rb | 47 ++++++++++++++++--------------- lib/fog/brightbox/oauth2.rb | 49 +++++++++++++++++++++++++++++++++ tests/brightbox/oauth2_tests.rb | 14 ++++------ 3 files changed, 78 insertions(+), 32 deletions(-) diff --git a/lib/fog/brightbox/compute.rb b/lib/fog/brightbox/compute.rb index fc96dea74..9c1548290 100644 --- a/lib/fog/brightbox/compute.rb +++ b/lib/fog/brightbox/compute.rb @@ -276,38 +276,37 @@ module Fog @credentials.refresh_token end - private - - def get_oauth_token - if authenticating_as_user? - token_strategy = UserCredentialsStrategy.new(@credentials) - else - token_strategy = ClientCredentialsStrategy.new(@credentials) + # Requests a new access token + # + # @return [String] New access token + def get_access_token + begin + get_access_token! + rescue Excon::Errors::Unauthorized, Excon::Errors::BadRequest + @credentials.update_tokens(nil, nil) end - - basic_header_to_encode = "#{@credentials.client_id}:#{@credentials.client_secret}" - - response = @auth_connection.request({ - :path => "/token", - :expects => 200, - :headers => { - 'Authorization' => "Basic " + Base64.encode64(basic_header_to_encode).chomp, - 'Content-Type' => 'application/json' - }, - :method => 'POST', - :body => Fog::JSON.encode(token_strategy.authorization_body_data) - }) - response_data = Fog::JSON.decode(response.body) - @credentials.update_tokens(response_data["access_token"], response_data["refresh_token"]) @credentials.access_token end + # Requests a new access token and raises if there is a problem + # + # @return [String] New access token + # @raise [Excon::Errors::BadRequest] The credentials are expired or incorrect + # + def get_access_token! + response = request_access_token(@auth_connection, @credentials) + update_credentials_from_response(@credentials, response) + @credentials.access_token + end + + private + def make_request(params) begin - get_oauth_token unless access_token_available? + get_access_token unless access_token_available? response = authenticated_request(params) rescue Excon::Errors::Unauthorized - get_oauth_token + get_access_token response = authenticated_request(params) end unless response.body.empty? diff --git a/lib/fog/brightbox/oauth2.rb b/lib/fog/brightbox/oauth2.rb index 530cf4d83..713b5abb7 100644 --- a/lib/fog/brightbox/oauth2.rb +++ b/lib/fog/brightbox/oauth2.rb @@ -5,6 +5,31 @@ # module Fog::Brightbox::OAuth2 + # This builds the simplest form of requesting an access token + # based on the arguments passed in + # + # @param [Fog::Connection] connection + # @param [CredentialSet] credentials + # + # @return [Excon::Response] + def request_access_token(connection, credentials) + token_strategy = credentials.best_grant_strategy + + header_content = "#{credentials.client_id}:#{credentials.client_secret}" + encoded_credentials = Base64.encode64(header_content).chomp + + connection.request({ + :path => "/token", + :expects => 200, + :headers => { + 'Authorization' => "Basic #{encoded_credentials}", + 'Content-Type' => 'application/json' + }, + :method => 'POST', + :body => Fog::JSON.encode(token_strategy.authorization_body_data) + }) + end + # Encapsulates credentials required to request access tokens from the # Brightbox authorisation servers # @@ -50,6 +75,18 @@ module Fog::Brightbox::OAuth2 @access_token = access_token @refresh_token = refresh_token end + + # Based on available credentials returns the best strategy + # + # @todo Add a means to dictate which should or shouldn't be used + # + def best_grant_strategy + if user_details? + UserCredentialsStrategy.new(self) + else + ClientCredentialsStrategy.new(self) + end + end end # This strategy class is the basis for OAuth2 grant types @@ -97,4 +134,16 @@ module Fog::Brightbox::OAuth2 } end end + +private + + # This updates the current credentials if passed a valid response + # + # @param [CredentialSet] credentials Credentials to update + # @param [Excon::Response] response Response object to parse value from + # + def update_credentials_from_response(credentials, response) + response_data = Fog::JSON.decode(response.body) + credentials.update_tokens(response_data["access_token"], response_data["refresh_token"]) + end end diff --git a/tests/brightbox/oauth2_tests.rb b/tests/brightbox/oauth2_tests.rb index 46fb82835..fd0c77ede 100644 --- a/tests/brightbox/oauth2_tests.rb +++ b/tests/brightbox/oauth2_tests.rb @@ -13,6 +13,9 @@ Shindo.tests("Fog::Brightbox::OAuth2", ["brightbox"]) do tests("#user_details?").returns(false) { credentials.user_details? } tests("#access_token?").returns(false) { credentials.access_token? } tests("#refresh_token?").returns(false) { credentials.refresh_token? } + tests("#best_grant_strategy").returns(true) do + credentials.best_grant_strategy.is_a?(Fog::Brightbox::OAuth2::ClientCredentialsStrategy) + end end tests("with user credentials") do @@ -21,14 +24,9 @@ Shindo.tests("Fog::Brightbox::OAuth2", ["brightbox"]) do tests("#user_details?").returns(true) { credentials.user_details? } tests("#access_token?").returns(false) { credentials.access_token? } tests("#refresh_token?").returns(false) { credentials.refresh_token? } - end - - tests("with existing tokens") do - options = {:username => @username, :access_token => @access_token, :refresh_token => @refresh_token} - credentials = Fog::Brightbox::OAuth2::CredentialSet.new(@client_id, @client_secret, options) - tests("#user_details?").returns(false) { credentials.user_details? } - tests("#access_token?").returns(true) { credentials.access_token? } - tests("#refresh_token?").returns(true) { credentials.refresh_token? } + tests("#best_grant_strategy").returns(true) do + credentials.best_grant_strategy.is_a?(Fog::Brightbox::OAuth2::UserCredentialsStrategy) + end end end From 424267321da2916063fea6ad5409cee301a0fc03 Mon Sep 17 00:00:00 2001 From: Paul Thornthwaite Date: Wed, 21 Nov 2012 13:59:59 +0000 Subject: [PATCH 06/10] [Brightbox] Adds support for refresh tokens Passing in a refresh token to `Compute#new` will allow the token to be used to request new access tokens as the original authenticated user so the username and password do not have to be stored locally. --- lib/fog/brightbox/oauth2.rb | 17 ++++++++++++++++- tests/brightbox/oauth2_tests.rb | 29 +++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/lib/fog/brightbox/oauth2.rb b/lib/fog/brightbox/oauth2.rb index 713b5abb7..45886e08b 100644 --- a/lib/fog/brightbox/oauth2.rb +++ b/lib/fog/brightbox/oauth2.rb @@ -81,7 +81,9 @@ module Fog::Brightbox::OAuth2 # @todo Add a means to dictate which should or shouldn't be used # def best_grant_strategy - if user_details? + if refresh_token? + RefreshTokenStrategy.new(self) + elsif user_details? UserCredentialsStrategy.new(self) else ClientCredentialsStrategy.new(self) @@ -135,6 +137,19 @@ module Fog::Brightbox::OAuth2 end end + # This strategy attempts to use a refresh_token gained during an earlier + # request to reuse the credentials given originally + # + class RefreshTokenStrategy < GrantTypeStrategy + def authorization_body_data + { + "grant_type" => "refresh_token", + "client_id" => @credentials.client_id, + "refresh_token" => @credentials.refresh_token + } + end + end + private # This updates the current credentials if passed a valid response diff --git a/tests/brightbox/oauth2_tests.rb b/tests/brightbox/oauth2_tests.rb index fd0c77ede..137cc147d 100644 --- a/tests/brightbox/oauth2_tests.rb +++ b/tests/brightbox/oauth2_tests.rb @@ -28,6 +28,17 @@ Shindo.tests("Fog::Brightbox::OAuth2", ["brightbox"]) do credentials.best_grant_strategy.is_a?(Fog::Brightbox::OAuth2::UserCredentialsStrategy) end end + + tests("with existing tokens") do + options = {:username => @username, :access_token => @access_token, :refresh_token => @refresh_token} + credentials = Fog::Brightbox::OAuth2::CredentialSet.new(@client_id, @client_secret, options) + tests("#user_details?").returns(false) { credentials.user_details? } + tests("#access_token?").returns(true) { credentials.access_token? } + tests("#refresh_token?").returns(true) { credentials.refresh_token? } + tests("#best_grant_strategy").returns(true) do + credentials.best_grant_strategy.is_a?(Fog::Brightbox::OAuth2::RefreshTokenStrategy) + end + end end tests("GrantTypeStrategy") do @@ -71,4 +82,22 @@ Shindo.tests("Fog::Brightbox::OAuth2", ["brightbox"]) do test("password == #{@password}") { authorization_body_data["password"] == @password } end end + + tests("RefreshTokenStrategy") do + refresh_token = "ab4b39dddf909" + options = {:refresh_token => refresh_token} + credentials = Fog::Brightbox::OAuth2::CredentialSet.new(@client_id, @client_secret, options) + strategy = Fog::Brightbox::OAuth2::RefreshTokenStrategy.new(credentials) + + tests("#respond_to? :authorization_body_data").returns(true) do + strategy.respond_to?(:authorization_body_data) + end + + tests("#authorization_body_data") do + authorization_body_data = strategy.authorization_body_data + test("grant_type == refresh_token") { authorization_body_data["grant_type"] == "refresh_token" } + test("client_id == #{@client_id}") { authorization_body_data["client_id"] == @client_id } + test("refresh_token == #{refresh_token}") { authorization_body_data["refresh_token"] == refresh_token } + end + end end From dbc53d0f48a8d8148abe2d73492487d8d51396b1 Mon Sep 17 00:00:00 2001 From: Paul Thornthwaite Date: Tue, 27 Nov 2012 10:11:03 +0000 Subject: [PATCH 07/10] [Brightbox] Adds option to disable token management Original request method handles missing tokens and the first Unauthorized response by requesting a new access token. This magic can be disruptive for clients so can be disabled by passing in `:brightbox_token_management => false` to the Compute service. --- lib/fog/brightbox/compute.rb | 65 ++++++++++++++++++++++++++------ tests/brightbox/compute_tests.rb | 34 ++++++++++++++++- 2 files changed, 87 insertions(+), 12 deletions(-) diff --git a/lib/fog/brightbox/compute.rb b/lib/fog/brightbox/compute.rb index 9c1548290..c0ed8efd0 100644 --- a/lib/fog/brightbox/compute.rb +++ b/lib/fog/brightbox/compute.rb @@ -20,6 +20,9 @@ module Fog # Cached tokens recognizes :brightbox_access_token, :brightbox_refresh_token + # Automatic token management + recognizes :brightbox_token_management + # Excon connection settings recognizes :persistent @@ -185,6 +188,7 @@ module Fog # @option options [Boolean] :persistent Sets a persistent HTTP {Fog::Connection} # @option options [String] :brightbox_access_token Sets the OAuth access token to use rather than requesting a new token # @option options [String] :brightbox_refresh_token Sets the refresh token to use when requesting a newer access token + # @option options [String] :brightbox_token_management Overide the existing behaviour to request access tokens if expired (default is `true`) # def initialize(options) # Currently authentication and api endpoints are the same but may change @@ -209,6 +213,8 @@ module Fog # If existing tokens have been cached, allow continued use of them in the service @credentials.update_tokens(options[:brightbox_access_token], options[:brightbox_refresh_token]) + + @token_management = options.fetch(:brightbox_token_management, true) end # Makes an API request to the given path using passed options or those @@ -234,7 +240,16 @@ module Fog } parameters[:account_id] = @scoped_account if parameters[:account_id].nil? && @scoped_account request_options[:body] = Fog::JSON.encode(parameters) unless parameters.empty? - make_request(request_options) + + response = make_request(request_options) + + # FIXME We should revert to returning the Excon::Request after a suitable + # configuration option is in place to switch back to this incorrect behaviour + unless response.body.empty? + Fog::JSON.decode(response.body) + else + response + end end # Returns the scoped account being used for requests @@ -301,23 +316,51 @@ module Fog private - def make_request(params) - begin - get_access_token unless access_token_available? - response = authenticated_request(params) - rescue Excon::Errors::Unauthorized - get_access_token - response = authenticated_request(params) - end - unless response.body.empty? - response = Fog::JSON.decode(response.body) + # This makes a request of the API based on the configured setting for + # token management. + # + # @param [Hash] options Excon compatible options + # @see https://github.com/geemus/excon/blob/master/lib/excon/connection.rb + # + # @return [Hash] Data of response body + # + def make_request(options) + if @token_management + managed_token_request(options) + else + authenticated_request(options) end end + # This request checks for access tokens and will ask for a new one if + # it receives Unauthorized from the API before repeating the request + # + # @param [Hash] options Excon compatible options + # + # @return [Excon::Response] + def managed_token_request(options) + begin + get_access_token unless access_token_available? + response = authenticated_request(options) + rescue Excon::Errors::Unauthorized + get_access_token + response = authenticated_request(options) + end + end + + # This request makes an authenticated request of the API using currently + # setup credentials. + # + # @param [Hash] options Excon compatible options + # + # @return [Excon::Response] def authenticated_request(options) headers = options[:headers] || {} headers.merge!("Authorization" => "OAuth #{@credentials.access_token}", "Content-Type" => "application/json") options[:headers] = headers + # TODO This is just a wrapper around a call to Excon::Connection#request + # so can be extracted from Compute by passing in the connection, + # credentials and options @connection.request(options) end diff --git a/tests/brightbox/compute_tests.rb b/tests/brightbox/compute_tests.rb index a3f043895..d166c22ca 100644 --- a/tests/brightbox/compute_tests.rb +++ b/tests/brightbox/compute_tests.rb @@ -20,7 +20,8 @@ Shindo.tests('Fog::Compute.new', ['brightbox']) do :brightbox_password => "password1234", :brightbox_account => "acc-12345", :brightbox_access_token => "12345abdef6789", - :brightbox_refresh_token => "12345abdef6789" + :brightbox_refresh_token => "12345abdef6789", + :brightbox_token_management => false }.each_pair do |option, sample| tests("recognises :#{option}").returns(true) do options = {:provider => "Brightbox"} @@ -34,4 +35,35 @@ Shindo.tests('Fog::Compute.new', ['brightbox']) do end end end + + tests("automatic token management") do + service_options = {:provider => "Brightbox"} + + tests("when enabled (default)") do + service_options[:brightbox_token_management] = true + + tests("using bad token") do + service_options[:brightbox_access_token] = "bad-token" + + tests("#request").returns(true, "returns a Hash") do + service = Fog::Compute.new(service_options) + response = service.get_authenticated_user + response.is_a?(Hash) # This is an outstanding issue, should be Excon::Response + end + end + end + + tests("when disabled") do + service_options[:brightbox_token_management] = false + + tests("using bad token") do + service_options[:brightbox_access_token] = "bad-token" + + tests("#request").raises(Excon::Errors::Unauthorized) do + service = Fog::Compute.new(service_options) + service.get_authenticated_user + end + end + end + end end From 31c5895119510427ec48cf0b85d2f5fe87cf4af2 Mon Sep 17 00:00:00 2001 From: Paul Thornthwaite Date: Tue, 27 Nov 2012 13:41:51 +0000 Subject: [PATCH 08/10] [Brightbox] Adds means to update scoped account A Compute instance encapsulates a connection for a client to the Brightbox API. Users can have multiple accounts but there was no easy way to switch between them when account had to be passed in via the initializer. Now the scoped account can be set on an existing instance which overrides any configured setting but it can be reset if needed. The #request method still can accept `account_id` as an option which again overrides the previous settings. Finally the parameter is now correctly sent as a query string parameter not part of the API request JSON. --- lib/fog/brightbox/compute.rb | 33 ++++++++++++++++++++++++++++++-- tests/brightbox/compute_tests.rb | 30 +++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/lib/fog/brightbox/compute.rb b/lib/fog/brightbox/compute.rb index c0ed8efd0..5cb6e2953 100644 --- a/lib/fog/brightbox/compute.rb +++ b/lib/fog/brightbox/compute.rb @@ -206,7 +206,9 @@ module Fog username = options[:brightbox_username] || Fog.credentials[:brightbox_username] password = options[:brightbox_password] || Fog.credentials[:brightbox_password] - @scoped_account = options[:brightbox_account] || Fog.credentials[:brightbox_account] + @configured_account = options[:brightbox_account] || Fog.credentials[:brightbox_account] + # Request account can be changed at anytime and changes behaviour of future requests + @scoped_account = @configured_account credential_options = {:username => username, :password => password} @credentials = CredentialSet.new(client_id, client_secret, credential_options) @@ -238,7 +240,13 @@ module Fog :path => path, :expects => expected_responses } - parameters[:account_id] = @scoped_account if parameters[:account_id].nil? && @scoped_account + + # Select the account to scope for this request + account = scoped_account(parameters.fetch(:account_id, nil)) + if account + request_options[:query] = { :account_id => account } + end + request_options[:body] = Fog::JSON.encode(parameters) unless parameters.empty? response = make_request(request_options) @@ -252,6 +260,27 @@ module Fog end end + # Sets the scoped account for future requests + # @param [String] scoped_account Identifier of the account to scope request to + def scoped_account=(scoped_account) + @scoped_account = scoped_account + end + + # This returns the account identifier that the request should be scoped by + # based on the options passed to the request and current configuration + # + # @param [String] options_account Any identifier passed into the request + # + # @return [String, nil] The account identifier to scope the request to or nil + def scoped_account(options_account = nil) + [options_account, @scoped_account].compact.first + end + + # Resets the scoped account back to intially configured one + def scoped_account_reset + @scoped_account = @configured_account + end + # Returns the scoped account being used for requests # # * For API clients this is the owning account diff --git a/tests/brightbox/compute_tests.rb b/tests/brightbox/compute_tests.rb index d166c22ca..c88c1b484 100644 --- a/tests/brightbox/compute_tests.rb +++ b/tests/brightbox/compute_tests.rb @@ -66,4 +66,34 @@ Shindo.tests('Fog::Compute.new', ['brightbox']) do end end end + + tests("account scoping") do + service = Fog::Compute.new(:provider => "Brightbox") + configured_account = Fog.credentials[:brightbox_account] + tests("when Fog.credentials are #{configured_account}") do + test("#scoped_account == #{configured_account}") { service.scoped_account == configured_account } + end + + set_account = "acc-35791" + tests("when Compute instance is updated to #{set_account}") do + service.scoped_account = set_account + test("#scoped_account == #{set_account}") { service.scoped_account == set_account } + end + + tests("when Compute instance is reset") do + service.scoped_account_reset + test("#scoped_account == #{configured_account}") { service.scoped_account == configured_account } + end + + optioned_account = "acc-56789" + tests("when Compute instance created with :brightbox_account => #{optioned_account}") do + service = Fog::Compute.new(:provider => "Brightbox", :brightbox_account => optioned_account ) + test("#scoped_account == #{optioned_account}") { service.scoped_account == optioned_account } + end + + request_account = "acc-24680" + tests("when requested with #{request_account}") do + test("#scoped_account(#{request_account}) == #{request_account}") { service.scoped_account(request_account) == request_account } + end + end end From 0c259286913f6dd0f3476f2ce619756f709a1519 Mon Sep 17 00:00:00 2001 From: Paul Thornthwaite Date: Thu, 29 Nov 2012 15:47:32 +0000 Subject: [PATCH 09/10] [Brightbox] Moves more of public API into Shared Moved a lot of helper methods to Shared so that the Mock version of the service is not erroring with missing methods even if requests are not implemented. --- lib/fog/brightbox/compute.rb | 167 +++++++++++++++++++---------------- 1 file changed, 91 insertions(+), 76 deletions(-) diff --git a/lib/fog/brightbox/compute.rb b/lib/fog/brightbox/compute.rb index 5cb6e2953..ef88d8b33 100644 --- a/lib/fog/brightbox/compute.rb +++ b/lib/fog/brightbox/compute.rb @@ -134,41 +134,6 @@ module Fog request :update_user module Shared - # Returns an identifier for the default image for use - # - # Currently tries to find the latest version Ubuntu LTS (i686) widening - # up to the latest, official version of Ubuntu available. - # - # Highly recommended that you actually select the image you want to run - # on your servers yourself! - # - # @return [String, nil] - def default_image - return @default_image_id unless @default_image_id.nil? - @default_image_id = Fog.credentials[:brightbox_default_image] || select_default_image - end - end - - class Mock - include Shared - - def initialize(options) - @brightbox_client_id = options[:brightbox_client_id] || Fog.credentials[:brightbox_client_id] - @brightbox_secret = options[:brightbox_secret] || Fog.credentials[:brightbox_secret] - end - - def request(options) - raise "Not implemented" - end - - private - def select_default_image - "img-mockd" - end - end - - class Real - include Shared include Fog::Brightbox::OAuth2 # Creates a new instance of the Brightbox Compute service @@ -219,47 +184,6 @@ module Fog @token_management = options.fetch(:brightbox_token_management, true) end - # Makes an API request to the given path using passed options or those - # set with the service setup - # - # @todo Standard Fog behaviour is to return the Excon::Response but - # this was unintentionally changed to be the Hash version of the - # data in the body. This loses access to some details and should - # be corrected in a backwards compatible manner - # - # @param [String] method HTTP method to use for the request - # @param [String] path The absolute path for the request - # @param [Array] expected_responses HTTP response codes that have been successful - # @param [Hash] parameters Keys and values for JSON - # @option parameters [String] :account_id The scoping account if required - # - # @return [Hash] - def request(method, path, expected_responses, parameters = {}) - request_options = { - :method => method.to_s.upcase, - :path => path, - :expects => expected_responses - } - - # Select the account to scope for this request - account = scoped_account(parameters.fetch(:account_id, nil)) - if account - request_options[:query] = { :account_id => account } - end - - request_options[:body] = Fog::JSON.encode(parameters) unless parameters.empty? - - response = make_request(request_options) - - # FIXME We should revert to returning the Excon::Request after a suitable - # configuration option is in place to switch back to this incorrect behaviour - unless response.body.empty? - Fog::JSON.decode(response.body) - else - response - end - end - # Sets the scoped account for future requests # @param [String] scoped_account Identifier of the account to scope request to def scoped_account=(scoped_account) @@ -343,6 +267,20 @@ module Fog @credentials.access_token end + # Returns an identifier for the default image for use + # + # Currently tries to find the latest version Ubuntu LTS (i686) widening + # up to the latest, official version of Ubuntu available. + # + # Highly recommended that you actually select the image you want to run + # on your servers yourself! + # + # @return [String, nil] + def default_image + return @default_image_id unless @default_image_id.nil? + @default_image_id = Fog.credentials[:brightbox_default_image] || select_default_image + end + private # This makes a request of the API based on the configured setting for @@ -392,6 +330,83 @@ module Fog # credentials and options @connection.request(options) end + end + + # The Mock Service allows you to run a fake instance of the Service + # which makes no real connections. + # + # @todo Implement + # + class Mock + include Shared + + def request(method, path, expected_responses, parameters = {}) + _request + end + + def request_access_token(connection, credentials) + _request + end + + private + + def _request + raise Fog::Errors::MockNotImplemented + end + + def select_default_image + "img-mockd" + end + end + + # The Real Service actually makes real connections to the Brightbox + # service. + # + class Real + include Shared + + # Makes an API request to the given path using passed options or those + # set with the service setup + # + # @todo Standard Fog behaviour is to return the Excon::Response but + # this was unintentionally changed to be the Hash version of the + # data in the body. This loses access to some details and should + # be corrected in a backwards compatible manner + # + # @param [String] method HTTP method to use for the request + # @param [String] path The absolute path for the request + # @param [Array] expected_responses HTTP response codes that have been successful + # @param [Hash] parameters Keys and values for JSON + # @option parameters [String] :account_id The scoping account if required + # + # @return [Hash] + def request(method, path, expected_responses, parameters = {}) + request_options = { + :method => method.to_s.upcase, + :path => path, + :expects => expected_responses + } + + # Select the account to scope for this request + account = scoped_account(parameters.fetch(:account_id, nil)) + if account + request_options[:query] = { :account_id => account } + end + + request_options[:body] = Fog::JSON.encode(parameters) unless parameters.empty? + + response = make_request(request_options) + + # FIXME We should revert to returning the Excon::Request after a suitable + # configuration option is in place to switch back to this incorrect behaviour + unless response.body.empty? + Fog::JSON.decode(response.body) + else + response + end + end + + private # Queries the API and tries to select the most suitable official Image # to use if the user chooses not to select their own. From 5d03a2398c0c9a4fdec7ccf23e577d39a5048878 Mon Sep 17 00:00:00 2001 From: Paul Thornthwaite Date: Thu, 29 Nov 2012 16:04:45 +0000 Subject: [PATCH 10/10] [Brightbox] Guards unimplemented mock tests This guards the newer tests just added that rely on unimplemented mocks --- tests/brightbox/compute_tests.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/brightbox/compute_tests.rb b/tests/brightbox/compute_tests.rb index c88c1b484..be47e7b13 100644 --- a/tests/brightbox/compute_tests.rb +++ b/tests/brightbox/compute_tests.rb @@ -46,6 +46,7 @@ Shindo.tests('Fog::Compute.new', ['brightbox']) do service_options[:brightbox_access_token] = "bad-token" tests("#request").returns(true, "returns a Hash") do + pending if Fog.mocking? service = Fog::Compute.new(service_options) response = service.get_authenticated_user response.is_a?(Hash) # This is an outstanding issue, should be Excon::Response @@ -60,6 +61,7 @@ Shindo.tests('Fog::Compute.new', ['brightbox']) do service_options[:brightbox_access_token] = "bad-token" tests("#request").raises(Excon::Errors::Unauthorized) do + pending if Fog.mocking? service = Fog::Compute.new(service_options) service.get_authenticated_user end