# Copyright (C) 2010 Google Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
gem 'faraday', '~> 0.7.0'
require 'faraday'
require 'faraday/utils'
require 'stringio'
require 'addressable/uri'
require 'signet'
require 'signet/errors'
require 'signet/oauth_2'
gem 'jwt', '~> 0.1.4'
require 'jwt'
module Signet
module OAuth2
class Client
##
# Creates an OAuth 2.0 client.
#
# @param [Hash] options
# The configuration parameters for the client.
# - :authorization_uri
—
# The authorization server's HTTP endpoint capable of
# authenticating the end-user and obtaining authorization.
# - :token_credential_uri
—
# The authorization server's HTTP endpoint capable of issuing
# tokens and refreshing expired tokens.
# - :client_id
—
# A unique identifier issued to the client to identify itself to the
# authorization server.
# - :client_secret
—
# A shared symmetric secret issued by the authorization server,
# which is used to authenticate the client.
# - :scope
—
# The scope of the access request, expressed either as an Array
# or as a space-delimited String.
# - :state
—
# An arbitrary string designed to allow the client to maintain state.
# - :code
—
# The authorization code received from the authorization server.
# - :redirect_uri
—
# The redirection URI used in the initial request.
# - :username
—
# The resource owner's username.
# - :password
—
# The resource owner's password.
# - :refresh_token
—
# The refresh token associated with the access token
# to be refreshed.
# - :access_token
—
# The current access token for this client.
# - :id_token
—
# The current ID token for this client.
# - :extension_parameters
—
# When using an extension grant type, this the set of parameters used
# by that extension.
#
# @example
# client = Signet::OAuth2::Client.new(
# :authorization_endpoint_uri =>
# 'https://example.server.com/authorization',
# :token_endpoint_uri =>
# 'https://example.server.com/token',
# :client_id => 'anonymous',
# :client_secret => 'anonymous',
# :scope => 'example',
# :redirect_uri => 'https://example.client.com/oauth'
# )
#
# @see Signet::OAuth2::Client#update!
def initialize(options={})
self.update!(options)
end
##
# Updates an OAuth 2.0 client.
#
# @param [Hash] options
# The configuration parameters for the client.
# - :authorization_uri
—
# The authorization server's HTTP endpoint capable of
# authenticating the end-user and obtaining authorization.
# - :token_credential_uri
—
# The authorization server's HTTP endpoint capable of issuing
# tokens and refreshing expired tokens.
# - :client_id
—
# A unique identifier issued to the client to identify itself to the
# authorization server.
# - :client_secret
—
# A shared symmetric secret issued by the authorization server,
# which is used to authenticate the client.
# - :scope
—
# The scope of the access request, expressed either as an Array
# or as a space-delimited String.
# - :state
—
# An arbitrary string designed to allow the client to maintain state.
# - :code
—
# The authorization code received from the authorization server.
# - :redirect_uri
—
# The redirection URI used in the initial request.
# - :username
—
# The resource owner's username.
# - :password
—
# The resource owner's password.
# - :refresh_token
—
# The refresh token associated with the access token
# to be refreshed.
# - :access_token
—
# The current access token for this client.
# - :id_token
—
# The current ID token for this client.
# - :extension_parameters
—
# When using an extension grant type, this the set of parameters used
# by that extension.
#
# @example
# client.update!(
# :code => 'i1WsRn1uB1',
# :access_token => 'FJQbwq9',
# :expires_in => 3600
# )
#
# @see Signet::OAuth2::Client#initialize
# @see Signet::OAuth2::Client#update_token!
def update!(options={})
# Normalize key to String to allow indifferent access.
options = options.inject({}) { |accu, (k, v)| accu[k.to_s] = v; accu }
self.authorization_uri = options["authorization_uri"]
self.token_credential_uri = options["token_credential_uri"]
self.client_id = options["client_id"]
self.client_secret = options["client_secret"]
self.scope = options["scope"]
self.state = options["state"]
self.code = options["code"]
self.redirect_uri = options["redirect_uri"]
self.username = options["username"]
self.password = options["password"]
self.extension_parameters = options["extension_parameters"] || {}
self.update_token!(options)
return self
end
##
# Updates an OAuth 2.0 client.
#
# @param [Hash] options
# The configuration parameters related to the token.
# - :refresh_token
—
# The refresh token associated with the access token
# to be refreshed.
# - :access_token
—
# The current access token for this client.
# - :id_token
—
# The current ID token for this client.
# - :expires_in
—
# The time in seconds until access token expiration.
# - :issued_at
—
# The timestamp that the token was issued at.
#
# @example
# client.update!(
# :refresh_token => 'n4E9O119d',
# :access_token => 'FJQbwq9',
# :expires_in => 3600
# )
#
# @see Signet::OAuth2::Client#initialize
# @see Signet::OAuth2::Client#update!
def update_token!(options={})
# Normalize key to String to allow indifferent access.
options = options.inject({}) { |accu, (k, v)| accu[k.to_s] = v; accu }
self.access_token = options["access_token"] if options["access_token"]
self.expires_in = options["expires_in"] if options["expires_in"]
# The refresh token may not be returned in a token response.
# In which case, the old one should continue to be used.
if options["refresh_token"]
self.refresh_token = options["refresh_token"]
end
# The ID token may not be returned in a token response.
# In which case, the old one should continue to be used.
if options["id_token"]
self.id_token = options["id_token"]
end
# By default, the token is issued at `Time.now` when `expires_in` is
# set, but this can be used to supply a more precise time.
if options["issued_at"]
self.issued_at = options["issued_at"]
end
return self
end
##
# Returns the authorization URI that the user should be redirected to.
#
# @return [Addressable::URI] The authorization URI.
#
# @see Signet::OAuth2.generate_authorization_uri
def authorization_uri(options={})
return nil if @authorization_uri == nil
unless options[:response_type]
options[:response_type] = :code
end
unless options[:access_type]
options[:access_type] = :offline
end
unless options[:approval_prompt]
# This default will likely change in the future.
options[:approval_prompt] = :force
end
options[:client_id] ||= self.client_id
options[:redirect_uri] ||= self.redirect_uri
if !options[:client_id]
raise ArgumentError, "Missing required client identifier."
end
unless options[:redirect_uri]
raise ArgumentError, "Missing required redirect URI."
end
if !options[:scope] && self.scope
options[:scope] = self.scope.join(' ')
end
options[:state] = self.state unless options[:state]
uri = Addressable::URI.parse(
::Signet::OAuth2.generate_authorization_uri(
@authorization_uri, options
)
)
if uri.normalized_scheme != 'https'
raise Signet::UnsafeOperationError,
'Authorization endpoint must be protected by TLS.'
end
return uri
end
##
# Sets the authorization URI for this client.
#
# @param [Addressable::URI, String, #to_str] new_authorization_uri
# The authorization URI.
def authorization_uri=(new_authorization_uri)
if new_authorization_uri != nil
new_authorization_uri =
Addressable::URI.parse(new_authorization_uri)
@authorization_uri = new_authorization_uri
else
@authorization_uri = nil
end
end
##
# Returns the token credential URI for this client.
#
# @return [Addressable::URI] The token credential URI.
def token_credential_uri
return @token_credential_uri
end
##
# Sets the token credential URI for this client.
#
# @param [Addressable::URI, String, #to_str] new_token_credential_uri
# The token credential URI.
def token_credential_uri=(new_token_credential_uri)
if new_token_credential_uri != nil
new_token_credential_uri =
Addressable::URI.parse(new_token_credential_uri)
@token_credential_uri = new_token_credential_uri
else
@token_credential_uri = nil
end
end
##
# Returns the client identifier for this client.
#
# @return [String] The client identifier.
def client_id
return @client_id
end
##
# Sets the client identifier for this client.
#
# @param [String] new_client_id
# The client identifier.
def client_id=(new_client_id)
@client_id = new_client_id
end
##
# Returns the client secret for this client.
#
# @return [String] The client secret.
def client_secret
return @client_secret
end
##
# Sets the client secret for this client.
#
# @param [String] new_client_secret
# The client secret.
def client_secret=(new_client_secret)
@client_secret = new_client_secret
end
##
# Returns the scope for this client. Scope is a list of access ranges
# defined by the authorization server.
#
# @return [Array] The scope of access the client is requesting.
def scope
return @scope
end
##
# Sets the scope for this client.
#
# @param [Array, String] new_scope
# The scope of access the client is requesting. This may be
# expressed as either an Array of String objects or as a
# space-delimited String.
def scope=(new_scope)
case new_scope
when Array
new_scope.each do |scope|
if scope.include?(' ')
raise ArgumentError,
"Individual scopes cannot contain the space character."
end
end
@scope = new_scope
when String
@scope = new_scope.split(' ')
when nil
@scope = nil
else
raise TypeError, "Expected Array or String, got #{new_scope.class}"
end
end
##
# Returns the client's current state value.
#
# @return [String] The state value.
def state
return @state
end
##
# Sets the client's current state value.
#
# @param [String] new_state
# The state value.
def state=(new_state)
@state = new_state
end
##
# Returns the authorization code issued to this client.
# Used only by the authorization code access grant type.
#
# @return [String] The authorization code.
def code
return @code
end
##
# Sets the authorization code issued to this client.
# Used only by the authorization code access grant type.
#
# @param [String] new_code
# The authorization code.
def code=(new_code)
@code = new_code
end
##
# Returns the redirect URI for this client.
#
# @return [String] The redirect URI.
def redirect_uri
return @redirect_uri
end
##
# Sets the redirect URI for this client.
#
# @param [String] new_redirect_uri
# The redirect URI.
def redirect_uri=(new_redirect_uri)
new_redirect_uri = Addressable::URI.parse(new_redirect_uri)
if new_redirect_uri == nil || new_redirect_uri.absolute?
@redirect_uri = new_redirect_uri
else
raise ArgumentError, "Redirect URI must be an absolute URI."
end
end
##
# Returns the username associated with this client.
# Used only by the resource owner password credential access grant type.
#
# @return [String] The username.
def username
return @username
end
##
# Sets the username associated with this client.
# Used only by the resource owner password credential access grant type.
#
# @param [String] new_username
# The username.
def username=(new_username)
@username = new_username
end
##
# Returns the password associated with this client.
# Used only by the resource owner password credential access grant type.
#
# @return [String] The password.
def password
return @password
end
##
# Sets the password associated with this client.
# Used only by the resource owner password credential access grant type.
#
# @param [String] new_password
# The password.
def password=(new_password)
@password = new_password
end
##
# Returns the set of extension parameters used by the client.
# Used only by extension access grant types.
#
# @return [Hash] The extension parameters.
def extension_parameters
return @extension_parameters ||= {}
end
##
# Sets extension parameters used by the client.
# Used only by extension access grant types.
#
# @param [Hash] new_extension_parameters
# The parameters.
def extension_parameters=(new_extension_parameters)
if new_extension_parameters.respond_to?(:to_hash)
@extension_parameters = new_extension_parameters.to_hash
else
raise TypeError,
"Expected Hash, got #{new_extension_parameters.class}."
end
end
##
# Returns the refresh token associated with this client.
#
# @return [String] The refresh token.
def refresh_token
return @refresh_token ||= nil
end
##
# Sets the refresh token associated with this client.
#
# @param [String] new_refresh_token
# The refresh token.
def refresh_token=(new_refresh_token)
@refresh_token = new_refresh_token
end
##
# Returns the access token associated with this client.
#
# @return [String] The access token.
def access_token
return @access_token ||= nil
end
##
# Sets the access token associated with this client.
#
# @param [String] new_access_token
# The access token.
def access_token=(new_access_token)
@access_token = new_access_token
end
##
# Returns the ID token associated with this client.
#
# @return [String] The ID token.
def id_token
return @id_token ||= nil
end
##
# Sets the ID token associated with this client.
#
# @param [String] new_id_token
# The ID token.
def id_token=(new_id_token)
@id_token = new_id_token
end
##
# Returns the decoded ID token associated with this client.
#
# @param [OpenSSL::PKey::RSA, Object] public_key
# The public key to use to verify the ID token. Skips verification if
# omitted.
#
# @return [String] The decoded ID token.
def decoded_id_token(public_key=nil)
JWT.decode(self.id_token, public_key, !!public_key)
end
##
# Returns the lifetime of the access token in seconds.
#
# @return [Integer] The access token lifetime.
def expires_in
return @expires_in
end
##
# Sets the lifetime of the access token in seconds. Resets the issued
# timestamp.
#
# @param [String] new_expires_in
# The access token lifetime.
def expires_in=(new_expires_in)
if new_expires_in != nil
@expires_in = new_expires_in.to_i
@issued_at = Time.now
else
@expires_in, @issued_at = nil, nil
end
end
##
# Returns the timestamp the access token was issued at.
#
# @return [Integer] The access token issuance time.
def issued_at
return @issued_at
end
##
# Sets the timestamp the access token was issued at.
#
# @param [String] new_issued_at
# The access token issuance time.
def issued_at=(new_issued_at)
@issued_at = new_issued_at
end
##
# Returns the timestamp the access token will expire at.
#
# @return [Integer] The access token lifetime.
def expires_at
if @issued_at && @expires_in
return @issued_at + @expires_in
else
return nil
end
end
##
# Returns true if the access token has expired.
#
# @return [TrueClass, FalseClass]
# The expiration state of the access token.
def expired?
return self.expires_at != nil && Time.now >= self.expires_at
end
##
# Returns the inferred grant type, based on the current state of the
# client object. Returns `"none"` if the client has insufficient
# information to make an in-band authorization request.
#
# @return [String]
# The inferred grant type.
def grant_type
if @grant_type
return @grant_type
else
if self.code && self.redirect_uri
'authorization_code'
elsif self.refresh_token
'refresh_token'
elsif self.username && self.password
'password'
else
# We don't have sufficient auth information, assume an out-of-band
# authorization arrangement between the client and server, or an
# extension grant type.
nil
end
end
end
def grant_type=(new_grant_type)
case new_grant_type
when 'authorization_code', 'refresh_token',
'password', 'client_credentials'
@grant_type = new_grant_type
else
@grant_type = Addressable::URI.parse(new_grant_type)
end
end
##
# Generates a request for token credentials.
#
# @param [Hash] options
# The configuration parameters for the request.
# - :code
—
# The authorization code.
#
# @return [Array] The request object.
def generate_access_token_request(options={})
if self.token_credential_uri == nil
raise ArgumentError, 'Missing token endpoint URI.'
end
if self.client_id == nil
raise ArgumentError, 'Missing client identifier.'
end
if self.client_secret == nil
raise ArgumentError, 'Missing client secret.'
end
method = 'POST'
parameters = {"grant_type" => self.grant_type}
case self.grant_type
when 'authorization_code'
parameters['code'] = self.code
parameters['redirect_uri'] = self.redirect_uri
when 'password'
parameters['username'] = self.username
parameters['password'] = self.password
when 'refresh_token'
parameters['refresh_token'] = self.refresh_token
else
if self.redirect_uri
# Grant type was intended to be `authorization_code` because of
# the presence of the redirect URI.
raise ArgumentError, 'Missing authorization code.'
end
end
parameters['client_id'] = self.client_id
parameters['client_secret'] = self.client_secret
headers = [
['Cache-Control', 'no-store'],
['Content-Type', 'application/x-www-form-urlencoded']
]
return Faraday::Request.create(method.to_s.downcase.to_sym) do |req|
req.url(Addressable::URI.parse(self.token_credential_uri))
req.headers = Faraday::Utils::Headers.new(headers)
req.body = Addressable::URI.form_encode(parameters)
end
end
def fetch_access_token(options={})
options[:connection] ||= Faraday.default_connection
request = self.generate_access_token_request(options)
request_env = request.to_env(options[:connection])
response = options[:connection].app.call(request_env)
if response.status.to_i == 200
return ::Signet::OAuth2.parse_json_credentials(response.body)
elsif [400, 401, 403].include?(response.status.to_i)
message = 'Authorization failed.'
if response.body.to_s.strip.length > 0
message += " Server message:\n#{response.body.to_s.strip}"
end
raise ::Signet::AuthorizationError.new(
message, :request => request, :response => response
)
else
message = "Unexpected status code: #{response.status}."
if response.body.to_s.strip.length > 0
message += " Server message:\n#{response.body.to_s.strip}"
end
raise ::Signet::AuthorizationError.new(
message, :request => request, :response => response
)
end
end
def fetch_access_token!(options={})
token_hash = self.fetch_access_token(options)
if token_hash
# No-op for grant types other than `authorization_code`.
# An authorization code is a one-time use token and is immediately
# revoked after usage.
self.code = nil
self.issued_at = Time.now
self.update_token!(token_hash)
end
return token_hash
end
##
# Generates an authenticated request for protected resources.
#
# @param [Hash] options
# The configuration parameters for the request.
# - :request
—
# A pre-constructed request. An OAuth 2 Authorization header
# will be added to it, as well as an explicit Cache-Control
# `no-store` directive.
# - :method
—
# The HTTP method for the request. Defaults to 'GET'.
# - :uri
—
# The URI for the request.
# - :headers
—
# The HTTP headers for the request.
# - :body
—
# The HTTP body for the request.
# - :realm
—
# The Authorization realm. See RFC 2617.
#
# @return [Array] The request object.
def generate_authenticated_request(options={})
if self.access_token == nil
raise ArgumentError, 'Missing access token.'
end
options = {
:realm => nil
}.merge(options)
if options[:request]
if options[:request].kind_of?(Array)
method, uri, headers, body = options[:request]
elsif options[:request].kind_of?(Faraday::Request)
unless options[:connection]
raise ArgumentError,
"Faraday::Request used, requires a connection to be provided."
end
method = options[:request].method.to_s.downcase.to_sym
uri = options[:connection].build_url(
options[:request].path, options[:request].params
)
headers = options[:request].headers || {}
body = options[:request].body || ''
end
else
method = options[:method] || :get
uri = options[:uri]
headers = options[:headers] || []
body = options[:body] || ''
end
headers = headers.to_a if headers.kind_of?(Hash)
request_components = {
:method => method,
:uri => uri,
:headers => headers,
:body => body
}
# Verify that we have all pieces required to return an HTTP request
request_components.each do |(key, value)|
unless value
raise ArgumentError, "Missing :#{key} parameter."
end
end
if !body.kind_of?(String) && body.respond_to?(:each)
# Just in case we get a chunked body
merged_body = StringIO.new
body.each do |chunk|
merged_body.write(chunk)
end
body = merged_body.string
end
if !body.kind_of?(String)
raise TypeError, "Expected String, got #{body.class}."
end
method = method.to_s.downcase.to_sym
headers << [
'Authorization',
::Signet::OAuth2.generate_bearer_authorization_header(
self.access_token,
options[:realm] ? [['realm', options[:realm]]] : nil
)
]
headers << ['Cache-Control', 'no-store']
return Faraday::Request.create(method.to_s.downcase.to_sym) do |req|
req.url(Addressable::URI.parse(uri))
req.headers = Faraday::Utils::Headers.new(headers)
req.body = body
end
end
##
# Transmits a request for a protected resource.
#
# @param [Hash] options
# The configuration parameters for the request.
# - :request
—
# A pre-constructed request. An OAuth 2 Authorization header
# will be added to it, as well as an explicit Cache-Control
# `no-store` directive.
# - :method
—
# The HTTP method for the request. Defaults to 'GET'.
# - :uri
—
# The URI for the request.
# - :headers
—
# The HTTP headers for the request.
# - :body
—
# The HTTP body for the request.
# - :realm
—
# The Authorization realm. See RFC 2617.
# - :connection
—
# The HTTP connection to use.
# Must be of type Faraday::Connection
.
#
# @example
# # Using Net::HTTP
# response = client.fetch_protected_resource(
# :uri => 'http://www.example.com/protected/resource'
# )
#
# @example
# # Using Typhoeus
# response = client.fetch_protected_resource(
# :request => Typhoeus::Request.new(
# 'http://www.example.com/protected/resource'
# ),
# :adapter => HTTPAdapter::TyphoeusAdapter.new,
# :connection => connection
# )
#
# @return [Array] The response object.
def fetch_protected_resource(options={})
options[:connection] ||= Faraday.default_connection
request = self.generate_authenticated_request(options)
request_env = request.to_env(options[:connection])
response = options[:connection].app.call(request_env)
if response.status.to_i == 401
# When accessing a protected resource, we only want to raise an
# error for 401 responses.
message = 'Authorization failed.'
if response.body.to_s.strip.length > 0
message += " Server message:\n#{response.body.to_s.strip}"
end
raise ::Signet::AuthorizationError.new(
message, :request => request, :response => response
)
else
return response
end
end
end
end
end