# Copyright (C) 2011 The Yakima Herald-Republic.
#
# 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.
#
require "faraday"
require "stringio"
require "addressable/uri"
require "signet"
require "signet/errors"
require "signet/oauth_1"
require "signet/oauth_1/credential"
module Signet
module OAuth1
class Server
# @return [Proc] lookup the value from this Proc.
attr_accessor :nonce_timestamp, :client_credential, :token_credential,
:temporary_credential, :verifier
##
# Creates an OAuth 1.0 server.
# @overload initialize(options)
# @param [Proc] nonce_timestamp verify a nonce/timestamp pair.
# @param [Proc] client_credential find a client credential.
# @param [Proc] token_credential find a token credential.
# @param [Proc] temporary_credential find a temporary credential.
# @param [Proc] verifier validate a verifier value.
#
# @example
# server = Signet::OAuth1::Server.new(
# :nonce_timestamp =>
# lambda { |n,t| OauthNonce.remember(n,t) },
# :client_credential =>
# lambda { |key| ClientCredential.find_by_key(key).to_hash },
# :token_credential =>
# lambda { |key| TokenCredential.find_by_key(key).to_hash },
# :temporary_credential =>
# lambda { |key| TemporaryCredential.find_by_key(key).to_hash },
# :verifier =>
# lambda {|verifier| Verifier.find_by_verifier(verifier).active? }
# )
def initialize options = {}
[:nonce_timestamp, :client_credential, :token_credential,
:temporary_credential, :verifier].each do |attr|
instance_variable_set "@#{attr}", options[attr]
end
end
# rubocop:disable Naming/UncommunicativeMethodParamName
# Constant time string comparison.
def safe_equals? a, b
check = a.bytesize ^ b.bytesize
a.bytes.zip(b.bytes) { |x, y| check |= x ^ y.to_i }
check.zero?
end
# rubocop:enable Naming/UncommunicativeMethodParamName
##
# Determine if the supplied nonce/timestamp pair is valid by calling
# the {#nonce_timestamp} Proc.
#
# @param [String, #to_str] nonce value from the request
# @param [String, #to_str] timestamp value from the request
# @return [Boolean] if the nonce/timestamp pair is valid.
def validate_nonce_timestamp nonce, timestamp
if @nonce_timestamp.respond_to? :call
nonce =
@nonce_timestamp.call nonce, timestamp
end
nonce ? true : false
end
##
# Find the appropriate client credential by calling
# the {#client_credential} Proc.
#
# @param [String] key provided to the {#client_credential} Proc.
# @return [Signet::OAuth1::Credential] The client credential.
def find_client_credential key
call_credential_lookup @client_credential, key
end
##
# Find the appropriate client credential by calling
# the {#token_credential} Proc.
#
# @param [String] key provided to the {#token_credential} Proc.
# @return [Signet::OAuth1::Credential] if the credential is found.
def find_token_credential key
call_credential_lookup @token_credential, key
end
##
# Find the appropriate client credential by calling
# the {#temporary_credential} Proc.
#
# @param [String] key provided to the {#temporary_credential} Proc.
# @return [Signet::OAuth1::Credential] if the credential is found.
def find_temporary_credential key
call_credential_lookup @temporary_credential, key
end
##
# Call a credential lookup, and cast the result to a proper Credential.
#
# @param [Proc] credential to call.
# @param [String] key provided to the Proc in credential
# @return [Signet::OAuth1::Credential] credential provided by
# credential
(if any).
def call_credential_lookup credential, key
cred = credential.call key if
credential.respond_to? :call
return nil if cred.nil?
return nil unless cred.respond_to?(:to_str) ||
cred.respond_to?(:to_ary) ||
cred.respond_to?(:to_hash)
if cred.instance_of? ::Signet::OAuth1::Credential
cred
else
::Signet::OAuth1::Credential.new cred
end
end
##
# Determine if the verifier is valid by calling the Proc in {#verifier}.
#
# @param [String] verifier Key provided to the {#verifier} Proc.
# @return [Boolean] if the verifier Proc returns anything other than
# nil
or false
.
def find_verifier verifier
verified = @verifier.call verifier if @verifier.respond_to? :call
verified ? true : false
end
# rubocop:disable Metrics/MethodLength
# rubocop:disable Metrics/PerceivedComplexity
##
# Validate and normalize the components from an HTTP request.
# @overload verify_request_components(options)
# @param [Faraday::Request] request A pre-constructed request to verify.
# @param [String] method the HTTP method , defaults to GET
# @param [Addressable::URI, String] uri the URI .
# @param [Hash, Array] headers the HTTP headers.
# @param [StringIO, String] body The HTTP body.
# @param [HTTPAdapter] adapter The HTTP adapter(optional).
# @return [Hash] normalized request components
def verify_request_components options = {}
if options[:request]
if options[:request].is_a?(Faraday::Request) || options[:request].is_a?(Array)
request = options[:request]
elsif options[:adapter]
request = options[:adapter].adapt_request options[:request]
end
method = request.method
uri = request.path
headers = request.headers
body = request.body
else
method = options[:method] || :get
uri = options[:uri]
headers = options[:headers] || []
body = options[:body] || ""
end
headers = headers.to_a if headers.is_a? Hash
method = method.to_s.upcase
request_components = {
method: method,
uri: uri,
headers: headers
}
# Verify that we have all the pieces required to validate the HTTP request
request_components.each do |(key, value)|
raise ArgumentError, "Missing :#{key} parameter." unless value
end
request_components[:body] = body
request_components
end
# rubocop:enable Metrics/MethodLength
# rubocop:enable Metrics/PerceivedComplexity
##
# Validate and normalize the HTTP Authorization header.
#
# @param [Array] headers from HTTP request.
# @return [Hash] Hash of Authorization header.
def verify_auth_header_components headers
auth_header = headers.find { |x| x[0] == "Authorization" }
raise MalformedAuthorizationError, "Authorization header is missing" if auth_header.nil? || auth_header[1] == ""
auth_hash = ::Signet::OAuth1.parse_authorization_header(
auth_header[1]
).each_with_object({}) { |(key, val), acc| acc[key.downcase] = val; }
auth_hash
end
##
# @overload request_realm(options)
# @param [Hash] request A pre-constructed request to verify.
# @param [String] method the HTTP method , defaults to GET
# @param [Addressable::URI, String] uri the URI .
# @param [Hash, Array] headers the HTTP headers.
# @param [StringIO, String] body The HTTP body.
# @param [HTTPAdapter] adapter The HTTP adapter(optional).
# @return [String] The Authorization realm(see RFC 2617) of the request.
def request_realm options = {}
request_components = if options[:request]
verify_request_components(
request: options[:request],
adapter: options[:adapter]
)
else
verify_request_components(
method: options[:method],
uri: options[:uri],
headers: options[:headers],
body: options[:body]
)
end
auth_header = request_components[:headers].find { |x| x[0] == "Authorization" }
raise MalformedAuthorizationError, "Authorization header is missing" if auth_header.nil? || auth_header[1] == ""
auth_hash = ::Signet::OAuth1.parse_authorization_header(
auth_header[1]
).each_with_object({}) { |(key, val), acc| acc[key.downcase] = val; }
auth_hash["realm"]
end
# rubocop:disable Metrics/AbcSize
# rubocop:disable Metrics/MethodLength
# rubocop:disable Metrics/PerceivedComplexity
##
# Authenticates a temporary credential request. If no oauth_callback is
# present in the request, oob
will be returned.
#
# @overload authenticate_temporary_credential_request(options)
# @param [Hash] request The configuration parameters for the request.
# @param [String] method the HTTP method , defaults to GET
# @param [Addressable::URI, String] uri the URI .
# @param [Hash, Array] headers the HTTP headers.
# @param [StringIO, String] body The HTTP body.
# @param [HTTPAdapter] adapter The HTTP adapter(optional).
# @return [String] The oauth_callback value, or false
if not valid.
def authenticate_temporary_credential_request options = {}
verifications = {
client_credential: lambda { |_x|
::Signet::OAuth1::Credential.new("Client credential key",
"Client credential secret")
}
}
verifications.each do |(key, _value)|
raise ArgumentError, "#{key} was not set." unless send key
end
request_components = if options[:request]
verify_request_components(
request: options[:request],
adapter: options[:adapter]
)
else
verify_request_components(
method: options[:method],
uri: options[:uri],
headers: options[:headers]
)
end
# body should be blank; we don't care in any case.
method = request_components[:method]
uri = request_components[:uri]
headers = request_components[:headers]
auth_hash = verify_auth_header_components headers
return false unless (client_credential = find_client_credential(
auth_hash["oauth_consumer_key"]
))
return false unless validate_nonce_timestamp(auth_hash["oauth_nonce"],
auth_hash["oauth_timestamp"])
client_credential_secret = client_credential.secret if client_credential
computed_signature = ::Signet::OAuth1.sign_parameters(
method,
uri,
# Realm isn't used, and will throw the signature off.
auth_hash.reject { |k, _v| k == "realm" }.to_a,
client_credential_secret,
nil
)
if safe_equals? computed_signature, auth_hash["oauth_signature"]
if auth_hash.fetch("oauth_callback", "oob").empty?
"oob"
else
auth_hash.fetch "oauth_callback"
end
else
false
end
end
# rubocop:enable Metrics/PerceivedComplexity
##
# Authenticates a token credential request.
# @overload authenticate_token_credential_request(options)
# @param [Hash] request The configuration parameters for the request.
# @param [String] method the HTTP method , defaults to GET
# @param [Addressable::URI, String] uri the URI .
# @param [Hash, Array] headers the HTTP headers.
# @param [StringIO, String] body The HTTP body.
# @param [HTTPAdapter] adapter The HTTP adapter(optional).
# @return [Hash] A hash of credentials and realm for a valid request,
# or nil
if not valid.
def authenticate_token_credential_request options = {}
verifications = {
client_credential: lambda { |_x|
::Signet::OAuth1::Credential.new("Client credential key",
"Client credential secret")
},
temporary_credential: lambda { |_x|
::Signet::OAuth1::Credential.new("Temporary credential key",
"Temporary credential secret")
},
verifier: ->(_x) { "Verifier" }
}
verifications.each do |(key, _value)|
raise ArgumentError, "#{key} was not set." unless send key
end
request_components = if options[:request]
verify_request_components(
request: options[:request],
adapter: options[:adapter]
)
else
verify_request_components(
method: options[:method],
uri: options[:uri],
headers: options[:headers],
body: options[:body]
)
end
# body should be blank; we don't care in any case.
method = request_components[:method]
uri = request_components[:uri]
headers = request_components[:headers]
auth_hash = verify_auth_header_components headers
return false unless (
client_credential = find_client_credential auth_hash["oauth_consumer_key"]
)
return false unless (
temporary_credential = find_temporary_credential auth_hash["oauth_token"]
)
return false unless validate_nonce_timestamp(
auth_hash["oauth_nonce"], auth_hash["oauth_timestamp"]
)
computed_signature = ::Signet::OAuth1.sign_parameters(
method,
uri,
# Realm isn't used, and will throw the signature off.
auth_hash.reject { |k, _v| k == "realm" }.to_a,
client_credential.secret,
temporary_credential.secret
)
return nil unless safe_equals? computed_signature, auth_hash["oauth_signature"]
{ client_credential: client_credential,
temporary_credential: temporary_credential,
realm: auth_hash["realm"] }
end
# rubocop:disable Metrics/CyclomaticComplexity
# rubocop:disable Metrics/PerceivedComplexity
##
# Authenticates a request for a protected resource.
# @overload authenticate_resource_request(options)
# @param [Hash] request The configuration parameters for the request.
# @param [String] method the HTTP method , defaults to GET
# @param [Addressable::URI, String] uri the URI .
# @param [Hash, Array] headers the HTTP headers.
# @param [StringIO, String] body The HTTP body.
# @param [Boolean] two_legged skip the token_credential lookup?
# @param [HTTPAdapter] adapter The HTTP adapter(optional).
#
# @return [Hash] A hash of the credentials and realm for a valid request,
# or nil
if not valid.
def authenticate_resource_request options = {}
verifications = {
client_credential: lambda do |_x|
::Signet::OAuth1::Credential.new("Client credential key",
"Client credential secret")
end
}
unless options[:two_legged] == true
verifications.update(
token_credential: lambda do |_x|
::Signet::OAuth1::Credential.new("Token credential key",
"Token credential secret")
end
)
end
# Make sure all required state is set
verifications.each do |(key, _value)|
raise ArgumentError, "#{key} was not set." unless send key
end
request_components = if options[:request]
verify_request_components(
request: options[:request],
adapter: options[:adapter]
)
else
verify_request_components(
method: options[:method],
uri: options[:uri],
headers: options[:headers],
body: options[:body]
)
end
method = request_components[:method]
uri = request_components[:uri]
headers = request_components[:headers]
body = request_components[:body]
if !body.is_a?(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
raise TypeError, "Expected String, got #{body.class}." unless body.is_a? String
media_type = nil
headers.each do |(header, value)|
media_type = value.gsub(/^([^;]+)(;.*?)?$/, '\1') if header.casecmp("Content-Type").zero?
end
auth_hash = verify_auth_header_components headers
auth_token = auth_hash["oauth_token"]
unless options[:two_legged]
return nil if auth_token.nil?
return nil unless (token_credential = find_token_credential auth_token)
token_credential_secret = token_credential.secret if token_credential
end
return nil unless (client_credential =
find_client_credential auth_hash["oauth_consumer_key"])
return nil unless validate_nonce_timestamp(auth_hash["oauth_nonce"],
auth_hash["oauth_timestamp"])
if method == ("POST" || "PUT") &&
media_type == "application/x-www-form-urlencoded"
request_components[:body] = body
post_parameters = Addressable::URI.form_unencode body
post_parameters.each { |param| param[1] = "" if param[1].nil? }
# If the auth header doesn't have the same params as the body, it
# can't have been signed correctly(5849#3.4.1.3)
unless post_parameters.sort == auth_hash.reject { |k, _v| k.index "oauth_" }.to_a.sort
raise MalformedAuthorizationError, "Request is of type application/x-www-form-urlencoded " \
"but Authentication header did not include form values"
end
end
client_credential_secret = client_credential.secret if client_credential
computed_signature = ::Signet::OAuth1.sign_parameters(
method,
uri,
# Realm isn't used, and will throw the signature off.
auth_hash.reject { |k, _v| k == "realm" }.to_a,
client_credential_secret,
token_credential_secret
)
return nil unless safe_equals? computed_signature, auth_hash["oauth_signature"]
{ client_credential: client_credential,
token_credential: token_credential,
realm: auth_hash["realm"] }
end
# rubocop:enable Metrics/AbcSize
# rubocop:enable Metrics/CyclomaticComplexity
# rubocop:enable Metrics/MethodLength
# rubocop:enable Metrics/PerceivedComplexity
end
end
end