Skip to content

Commit

Permalink
acu176188 prototype OAuth2 authentication
Browse files Browse the repository at this point in the history
  • Loading branch information
Tony Spataro committed Sep 23, 2014
1 parent 05aa73d commit 61a4df6
Show file tree
Hide file tree
Showing 2 changed files with 106 additions and 26 deletions.
127 changes: 101 additions & 26 deletions lib/right_api_client/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,22 +21,67 @@ class Client
DEFAULT_TIMEOUT = -1
DEFAULT_MAX_ATTEMPTS = 5

ROOT_RESOURCE = '/api/session'
ROOT_RESOURCE = '/api/session'
OAUTH_ENDPOINT = '/api/oauth2'
ROOT_INSTANCE_RESOURCE = '/api/session/instance'
DEFAULT_API_URL = 'https://my.rightscale.com'

# permitted parameters for initializing
AUTH_PARAMS = %w[
email password_base64 password account_id api_url api_version
cookies instance_token access_token timeout open_timeout max_attempts
email password_base64 password
instance_token
refresh_token access_token
cookies
account_id api_url api_version
timeout open_timeout max_attempts
enable_retry rest_client_class
]

attr_reader :cookies, :instance_token, :access_token, :last_request, :timeout, :open_timeout, :max_attempts, :enable_retry
attr_accessor :account_id, :api_url
# @return [String] OAuth 2.0 refresh token if provided
attr_reader :refresh_token

def initialize(args)
# @return [String] OAuth 2.0 access token, if present
attr_reader :access_token

# @return [Time] expiry timestamp for OAuth 2.0 access token
attr_reader :access_token_expires_at

attr_accessor :account_id

# @return [String] Base API url, e.g. https://us-3.rightscale.com
attr_accessor :api_url

# @return [String] instance API token as included in user-data
attr_reader :instance_token

# @return [Hash] collection of API cookies
# @deprecated please use OAuth 2.0 refresh tokens instead of password-based authentication
attr_reader :cookies

# @return [Hash] debug information about the last request and response
attr_reader :last_request

# @return [Integer] number of seconds to wait for socket open
attr_reader :open_timeout

# @return [Integer] number of seconds to wait for API response
attr_reader :timeout

# @return [Integer] number of times to retry idempotent requests (iff enable_retry == true)
attr_reader :max_attempts

# @return [Boolean] whether to retry idempotent requests that fail
attr_reader :enable_retry

email password_base64 password
instance_token
refresh_token access_token
cookies
account_id api_url api_version
timeout open_timeout max_attempts
enable_retry rest_client_class

def initialize(args)
raise 'This API client is only compatible with Ruby 1.8.7 and upwards.' if (RUBY_VERSION < '1.8.7')

@api_url, @api_version = DEFAULT_API_URL, API_VERSION
Expand All @@ -55,8 +100,9 @@ def initialize(args)
@rest_client = @rest_client_class.new(@api_url, :open_timeout => @open_timeout, :timeout => @timeout)
@last_request = {}

# There are four options for login:
# - credentials
# There are five options for login:
# - user email/password
# - user OAuth refresh token
# - instance API token
# - existing user-supplied cookies
# - existing user-supplied OAuth access token
Expand Down Expand Up @@ -103,7 +149,7 @@ def initialize(args)
end

def to_s
"#<RightApi::Client>"
"#<RightApi::Client #{api_url}>"
end

# Log HTTP calls to file (file can be STDOUT as well)
Expand Down Expand Up @@ -162,7 +208,7 @@ def retry_request(is_read_only = false)
end
rescue ApiError => e
if re_login?(e)
# Session cookie is expired or invalid
# Session is expired or invalid
login()
retry
else
Expand All @@ -172,23 +218,34 @@ def retry_request(is_read_only = false)
end

def login
params, path = if @instance_token
[ { 'instance_token' => @instance_token },
ROOT_INSTANCE_RESOURCE ]
account_href = "/api/accounts/#{@account_id}"

params, path =
if @refresh_token
[ {'grant_type' => 'refresh_token',
'refresh_token'=>@refresh_token},
OAUTH_ENDPOINT ]
elsif @instance_token
[ { 'instance_token' => @instance_token,
'account_href' => account_href },
ROOT_INSTANCE_RESOURCE ]
elsif @password_base64
[ { 'email' => @email, 'password' => Base64.decode64(@password_base64) },
[ { 'email' => @email,
'password' => Base64.decode64(@password_base64),
'account_href' => account_href },
ROOT_RESOURCE ]
else
[ { 'email' => @email, 'password' => @password },
[ { 'email' => @email,
'password' => @password,
'account_href' => account_href },
ROOT_RESOURCE ]
end
params['account_href'] = "/api/accounts/#{@account_id}"

response = nil
attempts = 0
begin
response = @rest_client[path].post(params, 'X-Api-Version' => @api_version) do |response, request, result, &block|
if response.code == 302
if [301, 302, 307].include?(response.code)
update_api_url(response)
response.follow_redirection(request, result, &block)
else
Expand All @@ -202,17 +259,24 @@ def login
retry
end

update_cookies(response)
if path == OAUTH_ENDPOINT
update_access_token(response)
else
update_cookies(response)
end
end

# Returns the request headers
def headers
h = {
'X-Api-Version' => @api_version,
'X-Account' => @account_id,
:accept => :json,
}

if @account_id
h['X-Account'] = @account_id
end

if @access_token
h['Authorization'] = "Bearer #{@access_token}"
elsif @cookies
Expand Down Expand Up @@ -396,11 +460,17 @@ def do_put(path, params={})
end
end

# Determine whether an exception can be fixed by logging in again.
# @return [Boolean] true if re-login is appropriate
def re_login?(e)
# cannot successfully re-login with only an access token; we want the
# expiration error to be raised.
return false if @access_token
e.message.index('403') && e.message =~ %r(.*Session cookie is expired or invalid)
auth_error =
(e.message.index('403') && e.message =~ %r(.*Session cookie is expired or invalid)) ||
e.message.index('401')

renewable_creds =
(@instance_token || (@email && (@password || @password_base64)) || @refresh_token)

auth_error && renewable_creds
end

# returns the resource_type
Expand All @@ -411,17 +481,23 @@ def get_resource_type(content_type)
# Makes sure the @cookies have a timestamp.
#
def timestamp_cookies

return unless @cookies

class << @cookies; attr_accessor :timestamp; end
@cookies.timestamp = Time.now
end

# Sets the @access_token and @access_token_expires_at
#
def update_access_token(response)
h = JSON.load(response)
@access_token = h['access_token']
@access_token_expires_at = Time.at(Time.now.to_i + Integer(h['expires_in']))
end

# Sets the @cookies (and timestamp it).
#
def update_cookies(response)

return unless response.cookies

(@cookies ||= {}).merge!(response.cookies)
Expand All @@ -432,7 +508,6 @@ def update_cookies(response)
# A helper class for error details
#
class ErrorDetails

attr_reader :method, :path, :params, :request, :response

def initialize(me, pt, ps, rq, rs)
Expand Down
5 changes: 5 additions & 0 deletions spec/functional/client_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@
client.access_token.should == "access token only"
end

it "accepts a refresh_token when creating a new client" do
client = RightApi::Client.new({:refresh_tokeb=> "refresh token only"})
client.refresh_token.should == "refresh token only"
end

it "post request works" do
deployment_session = flexmock("Deployment Session")
deployment_session.should_receive(:post).and_return(1)
Expand Down

0 comments on commit 61a4df6

Please sign in to comment.