Added ID token verification against server certificate.
[arvados.git] / lib / google / api_client.rb
1 # Copyright 2010 Google Inc.
2 #
3 # Licensed under the Apache License, Version 2.0 (the "License");
4 # you may not use this file except in compliance with the License.
5 # You may obtain a copy of the License at
6 #
7 #      http://www.apache.org/licenses/LICENSE-2.0
8 #
9 # Unless required by applicable law or agreed to in writing, software
10 # distributed under the License is distributed on an "AS IS" BASIS,
11 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 # See the License for the specific language governing permissions and
13 # limitations under the License.
14
15
16 gem 'faraday', '~> 0.7.0'
17 require 'faraday'
18 require 'faraday/utils'
19 require 'multi_json'
20 require 'stringio'
21
22 require 'google/api_client/version'
23 require 'google/api_client/errors'
24 require 'google/api_client/environment'
25 require 'google/api_client/discovery'
26 require 'google/api_client/reference'
27 require 'google/api_client/result'
28
29
30 module Google
31   # TODO(bobaman): Document all this stuff.
32
33
34   ##
35   # This class manages APIs communication.
36   class APIClient
37     ##
38     # Creates a new Google API client.
39     #
40     # @param [Hash] options The configuration parameters for the client.
41     # @option options [Symbol, #generate_authenticated_request] :authorization
42     #   (:oauth_1)
43     #   The authorization mechanism used by the client.  The following
44     #   mechanisms are supported out-of-the-box:
45     #   <ul>
46     #     <li><code>:two_legged_oauth_1</code></li>
47     #     <li><code>:oauth_1</code></li>
48     #     <li><code>:oauth_2</code></li>
49     #   </ul>
50     # @option options [String] :host ("www.googleapis.com")
51     #   The API hostname used by the client.  This rarely needs to be changed.
52     # @option options [String] :application_name
53     #   The name of the application using the client.
54     # @option options [String] :application_version
55     #   The version number of the application using the client.
56     # @option options [String] :user_agent
57     #   ("{app_name} google-api-ruby-client/{version} {os_name}/{os_version}")
58     #   The user agent used by the client.  Most developers will want to
59     #   leave this value alone and use the `:application_name` option instead.
60     def initialize(options={})
61       # Normalize key to String to allow indifferent access.
62       options = options.inject({}) do |accu, (key, value)|
63         accu[key.to_s] = value
64         accu
65       end
66       # Almost all API usage will have a host of 'www.googleapis.com'.
67       self.host = options["host"] || 'www.googleapis.com'
68       # Most developers will want to leave this value alone and use the
69       # application_name option.
70       application_string = (
71         options["application_name"] ? (
72           "#{options["application_name"]}/" +
73           "#{options["application_version"] || '0.0.0'}"
74         ) : ""
75       )
76       self.user_agent = options["user_agent"] || (
77         "#{application_string} " +
78         "google-api-ruby-client/#{VERSION::STRING} " +
79          ENV::OS_VERSION
80       ).strip
81       # The writer method understands a few Symbols and will generate useful
82       # default authentication mechanisms.
83       self.authorization = options["authorization"] || :oauth_2
84       self.key = options["key"]
85       self.user_ip = options["user_ip"]
86       @discovery_uris = {}
87       @discovery_documents = {}
88       @discovered_apis = {}
89       return self
90     end
91
92     ##
93     # Returns the authorization mechanism used by the client.
94     #
95     # @return [#generate_authenticated_request] The authorization mechanism.
96     attr_reader :authorization
97
98     ##
99     # Sets the authorization mechanism used by the client.
100     #
101     # @param [#generate_authenticated_request] new_authorization
102     #   The new authorization mechanism.
103     def authorization=(new_authorization)
104       case new_authorization
105       when :oauth_1, :oauth
106         gem 'signet', '~> 0.3.0'
107         require 'signet/oauth_1/client'
108         # NOTE: Do not rely on this default value, as it may change
109         new_authorization = Signet::OAuth1::Client.new(
110           :temporary_credential_uri =>
111             'https://www.google.com/accounts/OAuthGetRequestToken',
112           :authorization_uri =>
113             'https://www.google.com/accounts/OAuthAuthorizeToken',
114           :token_credential_uri =>
115             'https://www.google.com/accounts/OAuthGetAccessToken',
116           :client_credential_key => 'anonymous',
117           :client_credential_secret => 'anonymous'
118         )
119       when :two_legged_oauth_1, :two_legged_oauth
120         gem 'signet', '~> 0.3.0'
121         require 'signet/oauth_1/client'
122         # NOTE: Do not rely on this default value, as it may change
123         new_authorization = Signet::OAuth1::Client.new(
124           :client_credential_key => nil,
125           :client_credential_secret => nil,
126           :two_legged => true
127         )
128       when :oauth_2
129         gem 'signet', '~> 0.3.0'
130         require 'signet/oauth_2/client'
131         # NOTE: Do not rely on this default value, as it may change
132         new_authorization = Signet::OAuth2::Client.new(
133           :authorization_uri =>
134             'https://accounts.google.com/o/oauth2/auth',
135           :token_credential_uri =>
136             'https://accounts.google.com/o/oauth2/token'
137         )
138       when nil
139         # No authorization mechanism
140       else
141         if !new_authorization.respond_to?(:generate_authenticated_request)
142           raise TypeError,
143             'Expected authorization mechanism to respond to ' +
144             '#generate_authenticated_request.'
145         end
146       end
147       @authorization = new_authorization
148       return @authorization
149     end
150
151     ##
152     # The application's API key issued by the API console.
153     #
154     # @return [String] The API key.
155     attr_accessor :key
156
157     ##
158     # The IP address of the user this request is being performed on behalf of.
159     #
160     # @return [String] The user's IP address.
161     attr_accessor :user_ip
162
163     ##
164     # The API hostname used by the client.
165     #
166     # @return [String]
167     #   The API hostname.  Should almost always be 'www.googleapis.com'.
168     attr_accessor :host
169
170     ##
171     # The user agent used by the client.
172     #
173     # @return [String]
174     #   The user agent string used in the User-Agent header.
175     attr_accessor :user_agent
176
177     ##
178     # Returns the URI for the directory document.
179     #
180     # @return [Addressable::URI] The URI of the directory document.
181     def directory_uri
182       template = Addressable::Template.new(
183         "https://{host}/discovery/v1/apis"
184       )
185       return template.expand({"host" => self.host})
186     end
187
188     ##
189     # Manually registers a URI as a discovery document for a specific version
190     # of an API.
191     #
192     # @param [String, Symbol] api The API name.
193     # @param [String] version The desired version of the API.
194     # @param [Addressable::URI] uri The URI of the discovery document.
195     def register_discovery_uri(api, version, uri)
196       api = api.to_s
197       version = version || 'v1'
198       @discovery_uris["#{api}:#{version}"] = uri
199     end
200
201     ##
202     # Returns the URI for the discovery document.
203     #
204     # @param [String, Symbol] api The API name.
205     # @param [String] version The desired version of the API.
206     # @return [Addressable::URI] The URI of the discovery document.
207     def discovery_uri(api, version=nil)
208       api = api.to_s
209       version = version || 'v1'
210       return @discovery_uris["#{api}:#{version}"] ||= (begin
211         template = Addressable::Template.new(
212           "https://{host}/discovery/v1/apis/" +
213           "{api}/{version}/rest"
214         )
215         template.expand({
216           "host" => self.host,
217           "api" => api,
218           "version" => version
219         })
220       end)
221     end
222
223     ##
224     # Manually registers a pre-loaded discovery document for a specific version
225     # of an API.
226     #
227     # @param [String, Symbol] api The API name.
228     # @param [String] version The desired version of the API.
229     # @param [String, StringIO] discovery_document
230     #   The contents of the discovery document.
231     def register_discovery_document(api, version, discovery_document)
232       api = api.to_s
233       version = version || 'v1'
234       if discovery_document.kind_of?(StringIO)
235         discovery_document.rewind
236         discovery_document = discovery_document.string
237       elsif discovery_document.respond_to?(:to_str)
238         discovery_document = discovery_document.to_str
239       else
240         raise TypeError,
241           "Expected String or StringIO, got #{discovery_document.class}."
242       end
243       @discovery_documents["#{api}:#{version}"] =
244         MultiJson.decode(discovery_document)
245     end
246
247     ##
248     # Returns the parsed directory document.
249     #
250     # @return [Hash] The parsed JSON from the directory document.
251     def directory_document
252       return @directory_document ||= (begin
253         request = self.generate_request(
254           :http_method => :get,
255           :uri => self.directory_uri,
256           :authenticated => false
257         )
258         response = self.transmit(:request => request)
259         if response.status >= 200 && response.status < 300
260           MultiJson.decode(response.body)
261         elsif response.status >= 400
262           case response.status
263           when 400...500
264             exception_type = ClientError
265           when 500...600
266             exception_type = ServerError
267           else
268             exception_type = TransmissionError
269           end
270           url = request.to_env(Faraday.default_connection)[:url]
271           raise exception_type,
272             "Could not retrieve directory document at: #{url}"
273         end
274       end)
275     end
276
277     ##
278     # Returns the parsed discovery document.
279     #
280     # @param [String, Symbol] api The API name.
281     # @param [String] version The desired version of the API.
282     # @return [Hash] The parsed JSON from the discovery document.
283     def discovery_document(api, version=nil)
284       api = api.to_s
285       version = version || 'v1'
286       return @discovery_documents["#{api}:#{version}"] ||= (begin
287         request = self.generate_request(
288           :http_method => :get,
289           :uri => self.discovery_uri(api, version),
290           :authenticated => false
291         )
292         response = self.transmit(:request => request)
293         if response.status >= 200 && response.status < 300
294           MultiJson.decode(response.body)
295         elsif response.status >= 400
296           case response.status
297           when 400...500
298             exception_type = ClientError
299           when 500...600
300             exception_type = ServerError
301           else
302             exception_type = TransmissionError
303           end
304           url = request.to_env(Faraday.default_connection)[:url]
305           raise exception_type,
306             "Could not retrieve discovery document at: #{url}"
307         end
308       end)
309     end
310
311     ##
312     # Returns all APIs published in the directory document.
313     #
314     # @return [Array] The list of available APIs.
315     def discovered_apis
316       @directory_apis ||= (begin
317         document_base = self.directory_uri
318         if self.directory_document && self.directory_document['items']
319           self.directory_document['items'].map do |discovery_document|
320             Google::APIClient::API.new(
321               document_base,
322               discovery_document
323             )
324           end
325         else
326           []
327         end
328       end)
329     end
330
331     ##
332     # Returns the service object for a given service name and service version.
333     #
334     # @param [String, Symbol] api The API name.
335     # @param [String] version The desired version of the API.
336     #
337     # @return [Google::APIClient::API] The service object.
338     def discovered_api(api, version=nil)
339       if !api.kind_of?(String) && !api.kind_of?(Symbol)
340         raise TypeError,
341           "Expected String or Symbol, got #{api.class}."
342       end
343       api = api.to_s
344       version = version || 'v1'
345       return @discovered_apis["#{api}:#{version}"] ||= begin
346         document_base = self.discovery_uri(api, version)
347         discovery_document = self.discovery_document(api, version)
348         if document_base && discovery_document
349           Google::APIClient::API.new(
350             document_base,
351             discovery_document
352           )
353         else
354           nil
355         end
356       end
357     end
358
359     ##
360     # Returns the method object for a given RPC name and service version.
361     #
362     # @param [String, Symbol] rpc_name The RPC name of the desired method.
363     # @param [String, Symbol] rpc_name The API the method is within.
364     # @param [String] version The desired version of the API.
365     #
366     # @return [Google::APIClient::Method] The method object.
367     def discovered_method(rpc_name, api, version=nil)
368       if !rpc_name.kind_of?(String) && !rpc_name.kind_of?(Symbol)
369         raise TypeError,
370           "Expected String or Symbol, got #{rpc_name.class}."
371       end
372       rpc_name = rpc_name.to_s
373       api = api.to_s
374       version = version || 'v1'
375       service = self.discovered_api(api, version)
376       if service.to_h[rpc_name]
377         return service.to_h[rpc_name]
378       else
379         return nil
380       end
381     end
382
383     ##
384     # Returns the service object with the highest version number.
385     #
386     # @note <em>Warning</em>: This method should be used with great care.
387     # As APIs are updated, minor differences between versions may cause
388     # incompatibilities. Requesting a specific version will avoid this issue.
389     #
390     # @param [String, Symbol] api The name of the service.
391     #
392     # @return [Google::APIClient::API] The service object.
393     def preferred_version(api)
394       if !api.kind_of?(String) && !api.kind_of?(Symbol)
395         raise TypeError,
396           "Expected String or Symbol, got #{api.class}."
397       end
398       api = api.to_s
399       return self.discovered_apis.detect do |a|
400         a.name == api && a.preferred == true
401       end
402     end
403
404     ##
405     # Verifies an ID token against a server certificate. Used to ensure that
406     # an ID token supplied by an untrusted client-side mechanism is valid.
407     # Raises an error if the token is invalid or missing.
408     def verify_id_token!
409       gem 'jwt', '~> 0.1.4'
410       require 'jwt'
411       require 'openssl'
412       @certificates ||= {}
413       if !self.authorization.respond_to?(:id_token)
414         raise ArgumentError, (
415           "Current authorization mechanism does not support ID tokens: " +
416           "#{self.authorization.class.to_s}"
417         )
418       elsif !self.authorization.id_token
419         raise ArgumentError, (
420           "Could not verify ID token, ID token missing. " +
421           "Scopes were: #{self.authorization.scope.inspect}"
422         )
423       else
424         check_cached_certs = lambda do
425           valid = false
426           for key, cert in @certificates
427             begin
428               self.authorization.decoded_id_token(cert.public_key)
429               valid = true
430             rescue JWT::DecodeError, Signet::UnsafeOperationError
431               # Expected exception. Ignore, ID token has not been validated.
432             end
433           end
434           valid
435         end
436         if check_cached_certs.call()
437           return true
438         end
439         request = self.generate_request(
440           :http_method => :get,
441           :uri => 'https://www.googleapis.com/oauth2/v1/certs',
442           :authenticated => false
443         )
444         response = self.transmit(:request => request)
445         if response.status >= 200 && response.status < 300
446           @certificates.merge!(
447             Hash[MultiJson.decode(response.body).map do |key, cert|
448               [key, OpenSSL::X509::Certificate.new(cert)]
449             end]
450           )
451         elsif response.status >= 400
452           case response.status
453           when 400...500
454             exception_type = ClientError
455           when 500...600
456             exception_type = ServerError
457           else
458             exception_type = TransmissionError
459           end
460           url = request.to_env(Faraday.default_connection)[:url]
461           raise exception_type,
462             "Could not retrieve certificates from: #{url}"
463         end
464         if check_cached_certs.call()
465           return true
466         else
467           raise InvalidIDTokenError,
468             "Could not verify ID token against any available certificate."
469         end
470       end
471       return nil
472     end
473
474     ##
475     # Generates a request.
476     #
477     # @option options [Google::APIClient::Method, String] :api_method
478     #   The method object or the RPC name of the method being executed.
479     # @option options [Hash, Array] :parameters
480     #   The parameters to send to the method.
481     # @option options [Hash, Array] :headers The HTTP headers for the request.
482     # @option options [String] :body The body of the request.
483     # @option options [String] :version ("v1")
484     #   The service version. Only used if `api_method` is a `String`.
485     # @option options [#generate_authenticated_request] :authorization
486     #   The authorization mechanism for the response. Used only if
487     #   `:authenticated` is `true`.
488     # @option options [TrueClass, FalseClass] :authenticated (true)
489     #   `true` if the request must be signed or somehow
490     #   authenticated, `false` otherwise.
491     #
492     # @return [Faraday::Request] The generated request.
493     #
494     # @example
495     #   request = client.generate_request(
496     #     :api_method => 'plus.activities.list',
497     #     :parameters =>
498     #       {'collection' => 'public', 'userId' => 'me'}
499     #   )
500     def generate_request(options={})
501       # Note: The merge method on a Hash object will coerce an API Reference
502       # object into a Hash and merge with the default options.
503       options={
504         :version => 'v1',
505         :authorization => self.authorization,
506         :key => self.key,
507         :user_ip => self.user_ip,
508         :connection => Faraday.default_connection
509       }.merge(options)
510       # The Reference object is going to need this to do method ID lookups.
511       options[:client] = self
512       # The default value for the :authenticated option depends on whether an
513       # authorization mechanism has been set.
514       if options[:authorization]
515         options = {:authenticated => true}.merge(options)
516       else
517         options = {:authenticated => false}.merge(options)
518       end
519       reference = Google::APIClient::Reference.new(options)
520       request = reference.to_request
521       if options[:authenticated]
522         request = self.generate_authenticated_request(
523           :request => request,
524           :connection => options[:connection]
525         )
526       end
527       return request
528     end
529
530     ##
531     # Signs a request using the current authorization mechanism.
532     #
533     # @param [Hash] options a customizable set of options
534     #
535     # @return [Faraday::Request] The signed or otherwise authenticated request.
536     def generate_authenticated_request(options={})
537       return authorization.generate_authenticated_request(options)
538     end
539
540     ##
541     # Transmits the request using the current HTTP adapter.
542     #
543     # @option options [Array, Faraday::Request] :request
544     #   The HTTP request to transmit.
545     # @option options [String, Symbol] :method
546     #   The method for the HTTP request.
547     # @option options [String, Addressable::URI] :uri
548     #   The URI for the HTTP request.
549     # @option options [Array, Hash] :headers
550     #   The headers for the HTTP request.
551     # @option options [String] :body
552     #   The body for the HTTP request.
553     # @option options [Faraday::Connection] :connection
554     #   The HTTP connection to use.
555     #
556     # @return [Faraday::Response] The response from the server.
557     def transmit(options={})
558       options[:connection] ||= Faraday.default_connection
559       if options[:request]
560         if options[:request].kind_of?(Array)
561           method, uri, headers, body = options[:request]
562         elsif options[:request].kind_of?(Faraday::Request)
563           unless options[:connection]
564             raise ArgumentError,
565               "Faraday::Request used, requires a connection to be provided."
566           end
567           method = options[:request].method.to_s.downcase.to_sym
568           uri = options[:connection].build_url(
569             options[:request].path, options[:request].params
570           )
571           headers = options[:request].headers || {}
572           body = options[:request].body || ''
573         end
574       else
575         method = options[:method] || :get
576         uri = options[:uri]
577         headers = options[:headers] || []
578         body = options[:body] || ''
579       end
580       headers = headers.to_a if headers.kind_of?(Hash)
581       request_components = {
582         :method => method,
583         :uri => uri,
584         :headers => headers,
585         :body => body
586       }
587       # Verify that we have all pieces required to transmit an HTTP request
588       request_components.each do |(key, value)|
589         unless value
590           raise ArgumentError, "Missing :#{key} parameter."
591         end
592       end
593
594       if self.user_agent != nil
595         # If there's no User-Agent header, set one.
596         unless headers.kind_of?(Enumerable)
597           # We need to use some Enumerable methods, relying on the presence of
598           # the #each method.
599           class <<headers
600             include Enumerable
601           end
602         end
603         if self.user_agent.kind_of?(String)
604           unless headers.any? { |k, v| k.downcase == 'User-Agent'.downcase }
605             headers = headers.to_a.insert(0, ['User-Agent', self.user_agent])
606           end
607         elsif self.user_agent != nil
608           raise TypeError,
609             "Expected User-Agent to be String, got #{self.user_agent.class}"
610         end
611       end
612
613       request = Faraday::Request.create(method.to_s.downcase.to_sym) do |req|
614         req.url(Addressable::URI.parse(uri))
615         req.headers = Faraday::Utils::Headers.new(headers)
616         req.body = body
617       end
618       request_env = request.to_env(options[:connection])
619       response = options[:connection].app.call(request_env)
620       return response
621     end
622
623     ##
624     # Executes a request, wrapping it in a Result object.
625     #
626     # @param [Google::APIClient::Method, String] api_method
627     #   The method object or the RPC name of the method being executed.
628     # @param [Hash, Array] parameters
629     #   The parameters to send to the method.
630     # @param [String] body The body of the request.
631     # @param [Hash, Array] headers The HTTP headers for the request.
632     # @option options [String] :version ("v1")
633     #   The service version. Only used if `api_method` is a `String`.
634     # @option options [#generate_authenticated_request] :authorization
635     #   The authorization mechanism for the response. Used only if
636     #   `:authenticated` is `true`.
637     # @option options [TrueClass, FalseClass] :authenticated (true)
638     #   `true` if the request must be signed or somehow
639     #   authenticated, `false` otherwise.
640     #
641     # @return [Google::APIClient::Result] The result from the API.
642     #
643     # @example
644     #   result = client.execute(
645     #     :api_method => 'plus.activities.list',
646     #     :parameters => {'collection' => 'public', 'userId' => 'me'}
647     #   )
648     #
649     # @see Google::APIClient#generate_request
650     def execute(*params)
651       # This block of code allows us to accept multiple parameter passing
652       # styles, and maintaining some backwards compatibility.
653       #
654       # Note: I'm extremely tempted to deprecate this style of execute call.
655       if params.last.respond_to?(:to_hash) && params.size == 1
656         options = params.pop
657       else
658         options = {}
659       end
660       options[:api_method] = params.shift if params.size > 0
661       options[:parameters] = params.shift if params.size > 0
662       options[:body] = params.shift if params.size > 0
663       options[:headers] = params.shift if params.size > 0
664       options[:client] = self
665
666       reference = Google::APIClient::Reference.new(options)
667       request = self.generate_request(reference)
668       response = self.transmit(
669         :request => request,
670         :connection => options[:connection]
671       )
672       return Google::APIClient::Result.new(reference, request, response)
673     end
674
675     ##
676     # Same as Google::APIClient#execute, but raises an exception if there was
677     # an error.
678     #
679     # @see Google::APIClient#execute
680     def execute!(*params)
681       result = self.execute(*params)
682       if result.data.respond_to?(:error) &&
683           result.data.error.respond_to?(:message)
684         # You're going to get a terrible error message if the response isn't
685         # parsed successfully as an error.
686         error_message = result.data.error.message
687       elsif result.data['error'] && result.data['error']['message']
688         error_message = result.data['error']['message']
689       end
690       if result.response.status >= 400
691         case result.response.status
692         when 400...500
693           exception_type = ClientError
694           error_message ||= "A client error has occurred."
695         when 500...600
696           exception_type = ServerError
697           error_message ||= "A server error has occurred."
698         else
699           exception_type = TransmissionError
700           error_message ||= "A transmission error has occurred."
701         end
702         raise exception_type, error_message
703       end
704       return result
705     end
706   end
707 end
708
709 require 'google/api_client/version'