diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 3e0d254..359c0c9 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -1,15 +1,31 @@ -# This configuration was generated by `rubocop --auto-gen-config` -# on 2015-05-18 09:38:28 -0700 using RuboCop version 0.31.0. +# This configuration was generated by +# `rubocop --auto-gen-config` +# on 2015-10-14 13:50:41 -0700 using RuboCop version 0.34.2. # The point is for the user to remove these configuration records # one by one as the offenses are removed from the code base. # Note that changes in the inspected code, or installation of new # versions of RuboCop, may require this file to be generated again. -# Offense count: 3 +# Offense count: 4 Metrics/AbcSize: - Max: 24 + Max: 27 -# Offense count: 10 +# Offense count: 1 +# Configuration parameters: CountComments. +Metrics/ClassLength: + Max: 109 + +# Offense count: 1 +Metrics/CyclomaticComplexity: + Max: 7 + +# Offense count: 16 # Configuration parameters: CountComments. Metrics/MethodLength: - Max: 13 + Max: 22 + +# Offense count: 2 +# Configuration parameters: EnforcedStyle, SupportedStyles. +Style/FormatString: + Exclude: + - 'lib/googleauth/user_authorizer.rb' diff --git a/.travis.yml b/.travis.yml index d479252..967fe25 100644 --- a/.travis.yml +++ b/.travis.yml @@ -7,6 +7,9 @@ rvm: - 1.9.3 - rbx-2 - jruby +matrix: + allow_failures: + - rbx-2 # See rubinius/rubinius#3485 - rubocop segfaults script: "bundle exec rake" addons: apt: diff --git a/Gemfile b/Gemfile index 82559b5..c53ac89 100755 --- a/Gemfile +++ b/Gemfile @@ -2,3 +2,21 @@ source 'https://rubygems.org' # Specify your gem's dependencies in googleauth.gemspec gemspec + +group :development do + gem 'bundler', '~> 1.9' + gem 'simplecov', '~> 0.9' + gem 'coveralls', '~> 0.7' + gem 'fakefs', '~> 0.6' + gem 'rake', '~> 10.0' + gem 'rubocop', '~> 0.30' + gem 'rspec', '~> 3.0' + gem 'redis', '~> 3.2' + gem 'fakeredis', '~> 0.5' + gem 'webmock', '~> 1.21' +end + +platforms :jruby do + group :development do + end +end diff --git a/googleauth.gemspec b/googleauth.gemspec index 813e419..482ad24 100755 --- a/googleauth.gemspec +++ b/googleauth.gemspec @@ -31,12 +31,4 @@ Gem::Specification.new do |s| s.add_dependency 'memoist', '~> 0.12' s.add_dependency 'multi_json', '~> 1.11' s.add_dependency 'signet', '~> 0.6' - - s.add_development_dependency 'bundler', '~> 1.9' - s.add_development_dependency 'simplecov', '~> 0.9' - s.add_development_dependency 'coveralls', '~> 0.7' - s.add_development_dependency 'fakefs', '~> 0.6' - s.add_development_dependency 'rake', '~> 10.0' - s.add_development_dependency 'rubocop', '~> 0.30' - s.add_development_dependency 'rspec', '~> 3.0' end diff --git a/lib/googleauth.rb b/lib/googleauth.rb index cacf94c..75294f1 100644 --- a/lib/googleauth.rb +++ b/lib/googleauth.rb @@ -34,6 +34,8 @@ require 'googleauth/credentials_loader' require 'googleauth/compute_engine' require 'googleauth/service_account' require 'googleauth/user_refresh' +require 'googleauth/client_id' +require 'googleauth/user_authorizer' module Google # Module Auth provides classes that provide Google-specific authorization @@ -56,11 +58,11 @@ END json_key_io, scope = options.values_at(:json_key_io, :scope) if json_key_io json_key, clz = determine_creds_class(json_key_io) - clz.new(json_key_io: StringIO.new(MultiJson.dump(json_key)), - scope: scope) + clz.make_creds(json_key_io: StringIO.new(MultiJson.dump(json_key)), + scope: scope) else clz = read_creds - clz.new(scope: scope) + clz.make_creds(scope: scope) end end diff --git a/lib/googleauth/client_id.rb b/lib/googleauth/client_id.rb new file mode 100644 index 0000000..21f1e23 --- /dev/null +++ b/lib/googleauth/client_id.rb @@ -0,0 +1,102 @@ +# Copyright 2014, Google Inc. +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +# * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +require 'multi_json' + +module Google + module Auth + # Representation of an application's identity for user authorization + # flows. + class ClientId + INSTALLED_APP = 'installed' + WEB_APP = 'web' + CLIENT_ID = 'client_id' + CLIENT_SECRET = 'client_secret' + MISSING_TOP_LEVEL_ELEMENT_ERROR = + "Expected top level property 'installed' or 'web' to be present." + + # Text identifier of the client ID + # @return [String] + attr_reader :id + + # Secret associated with the client ID + # @return [String] + attr_reader :secret + + class << self + attr_accessor :default + end + + # Initialize the Client ID + # + # @param [String] id + # Text identifier of the client ID + # @param [String] secret + # Secret associated with the client ID + # @note Direction instantion is discouraged to avoid embedding IDs + # & secrets in source. See {#from_file} to load from + # `client_secrets.json` files. + def initialize(id, secret) + fail 'Client id can not be nil' if id.nil? + fail 'Client secret can not be nil' if secret.nil? + @id = id + @secret = secret + end + + # Constructs a Client ID from a JSON file downloaed from the + # Google Developers Console. + # + # @param [String, File] file + # Path of file to read from + # @return [Google::Auth::ClientID] + def self.from_file(file) + fail 'File can not be nil.' if file.nil? + File.open(file.to_s) do |f| + json = f.read + config = MultiJson.load(json) + from_hash(config) + end + end + + # Constructs a Client ID from a previously loaded JSON file. The hash + # structure should + # match the expected JSON format. + # + # @param [hash] config + # Parsed contents of the JSON file + # @return [Google::Auth::ClientID] + def self.from_hash(config) + fail 'Hash can not be nil.' if config.nil? + raw_detail = config[INSTALLED_APP] || config[WEB_APP] + fail MISSING_TOP_LEVEL_ELEMENT_ERROR if raw_detail.nil? + ClientId.new(raw_detail[CLIENT_ID], raw_detail[CLIENT_SECRET]) + end + end + end +end diff --git a/lib/googleauth/scope_util.rb b/lib/googleauth/scope_util.rb new file mode 100644 index 0000000..5568d34 --- /dev/null +++ b/lib/googleauth/scope_util.rb @@ -0,0 +1,61 @@ +# Copyright 2015, Google Inc. +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +# * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +require 'googleauth/signet' +require 'googleauth/credentials_loader' +require 'multi_json' + +module Google + module Auth + # Small utility for normalizing scopes into canonical form + module ScopeUtil + ALIASES = { + 'email' => 'https://www.googleapis.com/auth/userinfo.email', + 'profile' => 'https://www.googleapis.com/auth/userinfo.profile', + 'openid' => 'https://www.googleapis.com/auth/plus.me' + } + + def self.normalize(scope) + list = as_array(scope) + list.map { |item| ALIASES[item] || item } + end + + def self.as_array(scope) + case scope + when Array + scope + when String + scope.split(' ') + else + fail 'Invalid scope value. Must be string or array' + end + end + end + end +end diff --git a/lib/googleauth/service_account.rb b/lib/googleauth/service_account.rb index 8e4fba2..901916f 100644 --- a/lib/googleauth/service_account.rb +++ b/lib/googleauth/service_account.rb @@ -49,6 +49,26 @@ module Google TOKEN_CRED_URI = 'https://www.googleapis.com/oauth2/v3/token' extend CredentialsLoader + # Creates a ServiceAccountCredentials. + # + # @param json_key_io [IO] an IO from which the JSON key can be read + # @param scope [string|array|nil] the scope(s) to access + def self.make_creds(options = {}) + json_key_io, scope = options.values_at(:json_key_io, :scope) + if json_key_io + private_key, client_email = read_json_key(json_key_io) + else + private_key = ENV[CredentialsLoader::PRIVATE_KEY_VAR] + client_email = ENV[CredentialsLoader::CLIENT_EMAIL_VAR] + end + + new(token_credential_uri: TOKEN_CRED_URI, + audience: TOKEN_CRED_URI, + scope: scope, + issuer: client_email, + signing_key: OpenSSL::PKey::RSA.new(private_key)) + end + # Reads the private key and client email fields from the service account # JSON key. def self.read_json_key(json_key_io) @@ -58,24 +78,8 @@ module Google [json_key['private_key'], json_key['client_email']] end - # Initializes a ServiceAccountCredentials. - # - # @param json_key_io [IO] an IO from which the JSON key can be read - # @param scope [string|array|nil] the scope(s) to access def initialize(options = {}) - json_key_io, scope = options.values_at(:json_key_io, :scope) - if json_key_io - private_key, client_email = self.class.read_json_key(json_key_io) - else - private_key = ENV[CredentialsLoader::PRIVATE_KEY_VAR] - client_email = ENV[CredentialsLoader::CLIENT_EMAIL_VAR] - end - - super(token_credential_uri: TOKEN_CRED_URI, - audience: TOKEN_CRED_URI, - scope: scope, - issuer: client_email, - signing_key: OpenSSL::PKey::RSA.new(private_key)) + super(options) end # Extends the base class. @@ -97,7 +101,8 @@ module Google client_email: @issuer } alt_clz = ServiceAccountJwtHeaderCredentials - alt = alt_clz.new(json_key_io: StringIO.new(MultiJson.dump(cred_json))) + key_io = StringIO.new(MultiJson.dump(cred_json)) + alt = alt_clz.make_creds(json_key_io: key_io) alt.apply!(a_hash) end end diff --git a/lib/googleauth/signet.rb b/lib/googleauth/signet.rb index 071ef72..d090dab 100644 --- a/lib/googleauth/signet.rb +++ b/lib/googleauth/signet.rb @@ -58,6 +58,25 @@ module Signet def updater_proc lambda(&method(:apply)) end + + def on_refresh(&block) + @refresh_listeners ||= [] + @refresh_listeners << block + end + + alias_method :orig_fetch_access_token!, :fetch_access_token! + def fetch_access_token!(options) + info = orig_fetch_access_token!(options) + notify_refresh_listeners + info + end + + def notify_refresh_listeners + listeners = @refresh_listeners || [] + listeners.each do |block| + block.call(self) + end + end end end end diff --git a/lib/googleauth/stores/file_token_store.rb b/lib/googleauth/stores/file_token_store.rb new file mode 100644 index 0000000..a769967 --- /dev/null +++ b/lib/googleauth/stores/file_token_store.rb @@ -0,0 +1,64 @@ +# Copyright 2014, Google Inc. +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +# * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +require 'yaml/store' +require 'googleauth/token_store' + +module Google + module Auth + module Stores + # Implementation of user token storage backed by a local YAML file + class FileTokenStore < Google::Auth::TokenStore + # Create a new store with the supplied file. + # + # @param [String, File] file + # Path to storage file + def initialize(options = {}) + path = options[:file] + @store = YAML::Store.new(path) + end + + # (see Google::Auth::Stores::TokenStore#load) + def load(id) + @store.transaction { @store[id] } + end + + # (see Google::Auth::Stores::TokenStore#store) + def store(id, token) + @store.transaction { @store[id] = token } + end + + # (see Google::Auth::Stores::TokenStore#delete) + def delete(id) + @store.transaction { @store.delete(id) } + end + end + end + end +end diff --git a/lib/googleauth/stores/redis_token_store.rb b/lib/googleauth/stores/redis_token_store.rb new file mode 100644 index 0000000..e065d46 --- /dev/null +++ b/lib/googleauth/stores/redis_token_store.rb @@ -0,0 +1,95 @@ +# Copyright 2014, Google Inc. +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +# * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +require 'redis' +require 'googleauth/token_store' + +module Google + module Auth + module Stores + # Implementation of user token storage backed by Redis. Tokens + # are stored as JSON using the supplied key, prefixed with + # `g-user-token:` + class RedisTokenStore < Google::Auth::TokenStore + DEFAULT_KEY_PREFIX = 'g-user-token:' + + # Create a new store with the supplied redis client. + # + # @param [::Redis, String] redis + # Initialized redis client to connect to. + # @param [String] prefix + # Prefix for keys in redis. Defaults to 'g-user-token:' + # @note If no redis instance is provided, a new one is created and + # the options passed through. You may include any other keys accepted + # by `Redis.new` + def initialize(options = {}) + redis = options.delete(:redis) + prefix = options.delete(:prefix) + case redis + when Redis + @redis = redis + else + @redis = Redis.new(options) + end + @prefix = prefix || DEFAULT_KEY_PREFIX + end + + # (see Google::Auth::Stores::TokenStore#load) + def load(id) + key = key_for(id) + @redis.get(key) + end + + # (see Google::Auth::Stores::TokenStore#store) + def store(id, token) + key = key_for(id) + @redis.set(key, token) + end + + # (see Google::Auth::Stores::TokenStore#delete) + def delete(id) + key = key_for(id) + @redis.del(key) + end + + private + + # Generate a redis key from a token ID + # + # @param [String] id + # ID of the token + # @return [String] + # Redis key + def key_for(id) + @prefix + id + end + end + end + end +end diff --git a/lib/googleauth/token_store.rb b/lib/googleauth/token_store.rb new file mode 100644 index 0000000..af1cf3b --- /dev/null +++ b/lib/googleauth/token_store.rb @@ -0,0 +1,69 @@ +# Copyright 2014, Google Inc. +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +# * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +module Google + module Auth + # Interface definition for token stores. It is not required that + # implementations inherit from this class. It is provided for documentation + # purposes to illustrate the API contract. + class TokenStore + class << self + attr_accessor :default + end + + # Load the token data from storage for the given ID. + # + # @param [String] id + # ID of token data to load. + # @return [String] + # The loaded token data. + def load(_id) + fail 'Not implemented' + end + + # Put the token data into storage for the given ID. + # + # @param [String] id + # ID of token data to store. + # @param [String] token + # The token data to store. + def store(_id, _token) + fail 'Not implemented' + end + + # Remove the token data from storage for the given ID. + # + # @param [String] id + # ID of the token data to delete + def delete(_id) + fail 'Not implemented' + end + end + end +end diff --git a/lib/googleauth/user_authorizer.rb b/lib/googleauth/user_authorizer.rb new file mode 100644 index 0000000..9ecd16c --- /dev/null +++ b/lib/googleauth/user_authorizer.rb @@ -0,0 +1,274 @@ +# Copyright 2014, Google Inc. +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +# * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +require 'uri' +require 'multi_json' +require 'googleauth/signet' +require 'googleauth/user_refresh' + +module Google + module Auth + # Handles an interactive 3-Legged-OAuth2 (3LO) user consent authorization. + # + # Example usage for a simple command line app: + # + # credentials = authorizer.get_credentials(user_id) + # if credentials.nil? + # url = authorizer.get_redirect_uri(user_id, + # nil, + # 'urn:ietf:wg:oauth:2.0:oob') + # puts "Open the following URL in the browser and enter the " + + # "resulting code after authorization" + # puts url + # code = gets + # creds = authorizer.get_and_store_credentials_from_code(user_id, + # code) + # end + # # Credentials ready to use, call APIs + # ... + class UserAuthorizer + MISMATCHED_CLIENT_ID_ERROR = + 'Token client ID of %s does not match configured client id %s' + NIL_CLIENT_ID_ERROR = 'Client id can not be nil.' + NIL_SCOPE_ERROR = 'Scope can not be nil.' + NIL_USER_ID_ERROR = 'User ID can not be nil.' + NIL_TOKEN_STORE_ERROR = 'Can not call method if token store is nil' + MISSING_ABSOLUTE_URL_ERROR = + 'Absolute base url required for relative callback url "%s"' + + # Initialize the authorizer + # + # @param [Google::Auth::ClientID] client_id + # Configured ID & secret for this application + # @param [String, Array] scope + # Authorization scope to request + # @param [Google::Auth::Stores::TokenStore] token_store + # Backing storage for persisting user credentials + # @param [String] callback_uri + # URL (either absolute or relative) of the auth callback. + # Defaults to '/oauth2callback' + def initialize(client_id, scope, token_store, callback_uri = nil) + fail NIL_CLIENT_ID_ERROR if client_id.nil? + fail NIL_SCOPE_ERROR if scope.nil? + + @client_id = client_id + @scope = Array(scope) + @token_store = token_store + @callback_uri = callback_uri || '/oauth2callback' + end + + # Build the URL for requesting authorization. + # + # @param [String] login_hint + # Login hint if need to authorize a specific account. Should be a + # user's email address or unique profile ID. + # @param [String] state + # Opaque state value to be returned to the oauth callback. + # @param [String] base_url + # Absolute URL to resolve the configured callback uri against. Required + # if the configured callback uri is a relative. + # @param [String, Array] scope + # Authorization scope to request. Overrides the instance scopes if not + # nil. + # @return [String] + # Authorization url + def get_authorization_url(options = {}) + scope = options[:scope] || @scope + credentials = UserRefreshCredentials.new( + client_id: @client_id.id, + client_secret: @client_id.secret, + scope: scope) + redirect_uri = redirect_uri_for(options[:base_url]) + url = credentials.authorization_uri(access_type: 'offline', + redirect_uri: redirect_uri, + approval_prompt: 'force', + state: options[:state], + include_granted_scopes: true, + login_hint: options[:login_hint]) + url.to_s + end + + # Fetch stored credentials for the user. + # + # @param [String] user_id + # Unique ID of the user for loading/storing credentials. + # @param [Array, String] scope + # If specified, only returns credentials that have all + # the requested scopes + # @return [Google::Auth::UserRefreshCredentials] + # Stored credentials, nil if none present + def get_credentials(user_id, scope = nil) + fail NIL_USER_ID_ERROR if user_id.nil? + fail NIL_TOKEN_STORE_ERROR if @token_store.nil? + + scope ||= @scope + saved_token = @token_store.load(user_id) + return nil if saved_token.nil? + data = MultiJson.load(saved_token) + + if data.fetch('client_id', @client_id.id) != @client_id.id + fail sprintf(MISMATCHED_CLIENT_ID_ERROR, + data['client_id'], @client_id.id) + end + + credentials = UserRefreshCredentials.new( + client_id: @client_id.id, + client_secret: @client_id.secret, + scope: data['scope'] || @scope, + access_token: data['access_token'], + refresh_token: data['refresh_token'], + expires_at: data.fetch('expiration_time_millis', 0) / 1000) + if credentials.includes_scope?(scope) + monitor_credentials(user_id, credentials) + return credentials + end + nil + end + + # Exchanges an authorization code returned in the oauth callback + # + # @param [String] user_id + # Unique ID of the user for loading/storing credentials. + # @param [String] code + # The authorization code from the OAuth callback + # @param [String, Array] scope + # Authorization scope requested. Overrides the instance + # scopes if not nil. + # @param [String] base_url + # Absolute URL to resolve the configured callback uri against. + # Required if the configured + # callback uri is a relative. + # @return [Google::Auth::UserRefreshCredentials] + # Credentials if exchange is successful + def get_credentials_from_code(options = {}) + user_id = options[:user_id] + code = options[:code] + scope = options[:scope] || @scope + base_url = options[:base_url] + credentials = UserRefreshCredentials.new( + client_id: @client_id.id, + client_secret: @client_id.secret, + redirect_uri: redirect_uri_for(base_url), + scope: scope) + credentials.code = code + credentials.fetch_access_token!({}) + monitor_credentials(user_id, credentials) + end + + # Exchanges an authorization code returned in the oauth callback. + # Additionally, stores the resulting credentials in the token store if + # the exchange is successful. + # + # @param [String] user_id + # Unique ID of the user for loading/storing credentials. + # @param [String] code + # The authorization code from the OAuth callback + # @param [String, Array] scope + # Authorization scope requested. Overrides the instance + # scopes if not nil. + # @param [String] base_url + # Absolute URL to resolve the configured callback uri against. + # Required if the configured + # callback uri is a relative. + # @return [Google::Auth::UserRefreshCredentials] + # Credentials if exchange is successful + def get_and_store_credentials_from_code(options = {}) + credentials = get_credentials_from_code(options) + monitor_credentials(options[:user_id], credentials) + store_credentials(options[:user_id], credentials) + end + + # Revokes a user's credentials. This both revokes the actual + # grant as well as removes the token from the token store. + # + # @param [String] user_id + # Unique ID of the user for loading/storing credentials. + def revoke_authorization(user_id) + credentials = get_credentials(user_id) + if credentials + begin + @token_store.delete(user_id) + ensure + credentials.revoke! + end + end + nil + end + + # Store credentials for a user. Generally not required to be + # called directly, but may be used to migrate tokens from one + # store to another. + # + # @param [String] user_id + # Unique ID of the user for loading/storing credentials. + # @param [Google::Auth::UserRefreshCredentials] credentials + # Credentials to store. + def store_credentials(user_id, credentials) + json = MultiJson.dump( + client_id: credentials.client_id, + access_token: credentials.access_token, + refresh_token: credentials.refresh_token, + scope: credentials.scope, + expiration_time_millis: (credentials.expires_at.to_i) * 1000) + @token_store.store(user_id, json) + credentials + end + + private + + # Begin watching a credential for refreshes so the access token can be + # saved. + # + # @param [String] user_id + # Unique ID of the user for loading/storing credentials. + # @param [Google::Auth::UserRefreshCredentials] credentials + # Credentials to store. + def monitor_credentials(user_id, credentials) + credentials.on_refresh do |cred| + store_credentials(user_id, cred) + end + credentials + end + + # Resolve the redirect uri against a base. + # + # @param [String] base_url + # Absolute URL to resolve the callback against if necessary. + # @return [String] + # Redirect URI + def redirect_uri_for(base_url) + return @callback_uri unless URI(@callback_uri).scheme.nil? + fail sprintf( + MISSING_ABSOLUTE_URL_ERROR, + @callback_uri) if base_url.nil? || URI(base_url).scheme.nil? + URI.join(base_url, @callback_uri).to_s + end + end + end +end diff --git a/lib/googleauth/user_refresh.rb b/lib/googleauth/user_refresh.rb index 65e99d0..20461f0 100644 --- a/lib/googleauth/user_refresh.rb +++ b/lib/googleauth/user_refresh.rb @@ -29,6 +29,7 @@ require 'googleauth/signet' require 'googleauth/credentials_loader' +require 'googleauth/scope_util' require 'multi_json' module Google @@ -46,8 +47,30 @@ module Google # cf [Application Default Credentials](http://goo.gl/mkAHpZ) class UserRefreshCredentials < Signet::OAuth2::Client TOKEN_CRED_URI = 'https://www.googleapis.com/oauth2/v3/token' + AUTHORIZATION_URI = 'https://accounts.google.com/o/oauth2/auth' + REVOKE_TOKEN_URI = 'https://accounts.google.com/o/oauth2/revoke' extend CredentialsLoader + # Create a UserRefreshCredentials. + # + # @param json_key_io [IO] an IO from which the JSON key can be read + # @param scope [string|array|nil] the scope(s) to access + def self.make_creds(options = {}) + json_key_io, scope = options.values_at(:json_key_io, :scope) + user_creds = read_json_key(json_key_io) if json_key_io + user_creds ||= { + 'client_id' => ENV[CredentialsLoader::CLIENT_ID_VAR], + 'client_secret' => ENV[CredentialsLoader::CLIENT_SECRET_VAR], + 'refresh_token' => ENV[CredentialsLoader::REFRESH_TOKEN_VAR] + } + + new(token_credential_uri: TOKEN_CRED_URI, + client_id: user_creds['client_id'], + client_secret: user_creds['client_secret'], + refresh_token: user_creds['refresh_token'], + scope: scope) + end + # Reads the client_id, client_secret and refresh_token fields from the # JSON key. def self.read_json_key(json_key_io) @@ -59,24 +82,38 @@ module Google json_key end - # Initializes a UserRefreshCredentials. - # - # @param json_key_io [IO] an IO from which the JSON key can be read - # @param scope [string|array|nil] the scope(s) to access def initialize(options = {}) - json_key_io, scope = options.values_at(:json_key_io, :scope) - user_creds = self.class.read_json_key(json_key_io) if json_key_io - user_creds ||= { - 'client_id' => ENV[CredentialsLoader::CLIENT_ID_VAR], - 'client_secret' => ENV[CredentialsLoader::CLIENT_SECRET_VAR], - 'refresh_token' => ENV[CredentialsLoader::REFRESH_TOKEN_VAR] - } + options ||= {} + options[:token_credential_uri] ||= TOKEN_CRED_URI + options[:authorization_uri] ||= AUTHORIZATION_URI + super(options) + end - super(token_credential_uri: TOKEN_CRED_URI, - client_id: user_creds['client_id'], - client_secret: user_creds['client_secret'], - refresh_token: user_creds['refresh_token'], - scope: scope) + # Revokes the credential + def revoke!(options = {}) + c = options[:connection] || Faraday.default_connection + resp = c.get(REVOKE_TOKEN_URI, token: refresh_token || access_token) + case resp.status + when 200 + self.access_token = nil + self.refresh_token = nil + self.expires_at = 0 + else + fail(Signet::AuthorizationError, + "Unexpected error code #{resp.status}") + end + end + + # Verifies that a credential grants the requested scope + # + # @param [Array, String] required_scope + # Scope to verify + # @return [Boolean] + # True if scope is granted + def includes_scope?(required_scope) + missing_scope = Google::Auth::ScopeUtil.normalize(required_scope) - + Google::Auth::ScopeUtil.normalize(scope) + missing_scope.empty? end end end diff --git a/spec/googleauth/apply_auth_examples.rb b/spec/googleauth/apply_auth_examples.rb index a0eca97..90c010d 100644 --- a/spec/googleauth/apply_auth_examples.rb +++ b/spec/googleauth/apply_auth_examples.rb @@ -56,17 +56,30 @@ shared_examples 'apply/apply! are OK' do # @make_auth_stubs, which should stub out the expected http behaviour of the # auth client describe '#fetch_access_token' do - it 'should set access_token to the fetched value' do - token = '1/abcdef1234567890' - stubs = make_auth_stubs access_token: token - c = Faraday.new do |b| + let(:token) { '1/abcdef1234567890' } + let(:stubs) do + make_auth_stubs access_token: token + end + let(:connection) do + Faraday.new do |b| b.adapter(:test, stubs) end + end - @client.fetch_access_token!(connection: c) + it 'should set access_token to the fetched value' do + @client.fetch_access_token!(connection: connection) expect(@client.access_token).to eq(token) stubs.verify_stubbed_calls end + + it 'should notify refresh listeners after updating' do + expect do |b| + @client.on_refresh(&b) + @client.fetch_access_token!(connection: connection) + end.to yield_with_args(have_attributes( + access_token: '1/abcdef1234567890')) + stubs.verify_stubbed_calls + end end describe '#apply!' do diff --git a/spec/googleauth/client_id_spec.rb b/spec/googleauth/client_id_spec.rb new file mode 100644 index 0000000..3a45b54 --- /dev/null +++ b/spec/googleauth/client_id_spec.rb @@ -0,0 +1,139 @@ +# Copyright 2015, Google Inc. +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +# * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +spec_dir = File.expand_path(File.join(File.dirname(__FILE__))) +$LOAD_PATH.unshift(spec_dir) +$LOAD_PATH.uniq! + +require 'fakefs/safe' +require 'googleauth' + +describe Google::Auth::ClientId do + shared_examples 'it has a valid config' do + it 'should include a valid id' do + expect(client_id.id).to eql 'abc@example.com' + end + + it 'should include a valid secret' do + expect(client_id.secret).to eql 'notasecret' + end + end + + shared_examples 'it can successfully load client_id' do + context 'loaded from hash' do + let(:client_id) { Google::Auth::ClientId.from_hash(config) } + + it_behaves_like 'it has a valid config' + end + + context 'loaded from file' do + file_path = '/client_secrets.json' + + let(:client_id) do + FakeFS do + content = MultiJson.dump(config) + File.write(file_path, content) + Google::Auth::ClientId.from_file(file_path) + end + end + + it_behaves_like 'it has a valid config' + end + end + + describe 'with web config' do + let(:config) do + { + 'web' => { + 'client_id' => 'abc@example.com', + 'client_secret' => 'notasecret' + } + } + end + it_behaves_like 'it can successfully load client_id' + end + + describe 'with installed app config' do + let(:config) do + { + 'installed' => { + 'client_id' => 'abc@example.com', + 'client_secret' => 'notasecret' + } + } + end + it_behaves_like 'it can successfully load client_id' + end + + context 'with missing top level property' do + let(:config) do + { + 'notvalid' => { + 'client_id' => 'abc@example.com', + 'client_secret' => 'notasecret' + } + } + end + + it 'should raise error' do + expect { Google::Auth::ClientId.from_hash(config) }.to raise_error( + /Expected top level property/) + end + end + + context 'with missing client id' do + let(:config) do + { + 'web' => { + 'client_secret' => 'notasecret' + } + } + end + + it 'should raise error' do + expect { Google::Auth::ClientId.from_hash(config) }.to raise_error( + /Client id can not be nil/) + end + end + + context 'with missing client secret' do + let(:config) do + { + 'web' => { + 'client_id' => 'abc@example.com' + } + } + end + + it 'should raise error' do + expect { Google::Auth::ClientId.from_hash(config) }.to raise_error( + /Client secret can not be nil/) + end + end +end diff --git a/spec/googleauth/scope_util_spec.rb b/spec/googleauth/scope_util_spec.rb new file mode 100644 index 0000000..bb10140 --- /dev/null +++ b/spec/googleauth/scope_util_spec.rb @@ -0,0 +1,75 @@ +# Copyright 2015, Google Inc. +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +# * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +spec_dir = File.expand_path(File.join(File.dirname(__FILE__))) +$LOAD_PATH.unshift(spec_dir) +$LOAD_PATH.uniq! + +require 'googleauth/scope_util' + +describe Google::Auth::ScopeUtil do + shared_examples 'normalizes scopes' do + let(:normalized) { Google::Auth::ScopeUtil.normalize(source) } + + it 'normalizes the email scope' do + expect(normalized).to include( + 'https://www.googleapis.com/auth/userinfo.email') + expect(normalized).to_not include 'email' + end + + it 'normalizes the profile scope' do + expect(normalized).to include( + 'https://www.googleapis.com/auth/userinfo.profile') + expect(normalized).to_not include 'profile' + end + + it 'normalizes the openid scope' do + expect(normalized).to include 'https://www.googleapis.com/auth/plus.me' + expect(normalized).to_not include 'openid' + end + + it 'leaves other other scopes as-is' do + expect(normalized).to include 'https://www.googleapis.com/auth/drive' + end + end + + context 'with scope as string' do + let(:source) do + 'email profile openid https://www.googleapis.com/auth/drive' + end + it_behaves_like 'normalizes scopes' + end + + context 'with scope as Array' do + let(:source) do + %w(email profile openid https://www.googleapis.com/auth/drive) + end + it_behaves_like 'normalizes scopes' + end +end diff --git a/spec/googleauth/service_account_spec.rb b/spec/googleauth/service_account_spec.rb index d8cf18f..8af2ff7 100644 --- a/spec/googleauth/service_account_spec.rb +++ b/spec/googleauth/service_account_spec.rb @@ -121,7 +121,7 @@ describe Google::Auth::ServiceAccountCredentials do before(:example) do @key = OpenSSL::PKey::RSA.new(2048) - @client = ServiceAccountCredentials.new( + @client = ServiceAccountCredentials.make_creds( json_key_io: StringIO.new(cred_json_text), scope: 'https://www.googleapis.com/auth/userinfo.profile' ) @@ -276,7 +276,7 @@ describe Google::Auth::ServiceAccountJwtHeaderCredentials do before(:example) do @key = OpenSSL::PKey::RSA.new(2048) - @client = clz.new(json_key_io: StringIO.new(cred_json_text)) + @client = clz.make_creds(json_key_io: StringIO.new(cred_json_text)) end def cred_json_text diff --git a/spec/googleauth/stores/file_token_store_spec.rb b/spec/googleauth/stores/file_token_store_spec.rb new file mode 100644 index 0000000..7936682 --- /dev/null +++ b/spec/googleauth/stores/file_token_store_spec.rb @@ -0,0 +1,58 @@ +# Copyright 2015, Google Inc. +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +# * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +spec_dir = File.expand_path(File.join(File.dirname(__FILE__))) +$LOAD_PATH.unshift(spec_dir) +$LOAD_PATH.uniq! + +require 'googleauth' +require 'googleauth/stores/file_token_store' +require 'spec_helper' +require 'fakefs/safe' +require 'fakefs/spec_helpers' +require 'googleauth/stores/store_examples' + +module FakeFS + class File + # FakeFS doesn't implement. And since we don't need to actually lock, + # just stub out... + def flock(*) + end + end +end + +describe Google::Auth::Stores::FileTokenStore do + include FakeFS::SpecHelpers + + let(:store) do + Google::Auth::Stores::FileTokenStore.new(file: '/tokens.yaml') + end + + it_behaves_like 'token store' +end diff --git a/spec/googleauth/stores/redis_token_store_spec.rb b/spec/googleauth/stores/redis_token_store_spec.rb new file mode 100644 index 0000000..050322c --- /dev/null +++ b/spec/googleauth/stores/redis_token_store_spec.rb @@ -0,0 +1,50 @@ +# Copyright 2015, Google Inc. +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +# * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +spec_dir = File.expand_path(File.join(File.dirname(__FILE__))) +$LOAD_PATH.unshift(spec_dir) +$LOAD_PATH.uniq! + +require 'googleauth' +require 'googleauth/stores/redis_token_store' +require 'spec_helper' +require 'fakeredis/rspec' +require 'googleauth/stores/store_examples' + +describe Google::Auth::Stores::RedisTokenStore do + let(:redis) do + Redis.new + end + + let(:store) do + Google::Auth::Stores::RedisTokenStore.new(redis: redis) + end + + it_behaves_like 'token store' +end diff --git a/spec/googleauth/stores/store_examples.rb b/spec/googleauth/stores/store_examples.rb new file mode 100644 index 0000000..deebf3f --- /dev/null +++ b/spec/googleauth/stores/store_examples.rb @@ -0,0 +1,58 @@ +# Copyright 2015, Google Inc. +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +# * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +spec_dir = File.expand_path(File.join(File.dirname(__FILE__))) +$LOAD_PATH.unshift(spec_dir) +$LOAD_PATH.uniq! + +require 'spec_helper' + +shared_examples 'token store' do + before(:each) do + store.store('default', 'test') + end + + it 'should return a stored value' do + expect(store.load('default')).to eq 'test' + end + + it 'should return nil for missing tokens' do + expect(store.load('notavalidkey')).to be_nil + end + + it 'should return nil for deleted tokens' do + store.delete('default') + expect(store.load('default')).to be_nil + end + + it 'should save overwrite values on store' do + store.store('default', 'test2') + expect(store.load('default')).to eq 'test2' + end +end diff --git a/spec/googleauth/user_authorizer_spec.rb b/spec/googleauth/user_authorizer_spec.rb new file mode 100644 index 0000000..eafd3c1 --- /dev/null +++ b/spec/googleauth/user_authorizer_spec.rb @@ -0,0 +1,314 @@ +# Copyright 2015, Google Inc. +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +# * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +spec_dir = File.expand_path(File.join(File.dirname(__FILE__))) +$LOAD_PATH.unshift(spec_dir) +$LOAD_PATH.uniq! + +require 'googleauth' +require 'googleauth/user_authorizer' +require 'uri' +require 'multi_json' +require 'spec_helper' + +describe Google::Auth::UserAuthorizer do + include TestHelpers + + let(:client_id) { Google::Auth::ClientId.new('testclient', 'notasecret') } + let(:scope) { %w(email profile) } + let(:token_store) { DummyTokenStore.new } + let(:callback_uri) { 'https://www.example.com/oauth/callback' } + let(:authorizer) do + Google::Auth::UserAuthorizer.new(client_id, + scope, + token_store, + callback_uri) + end + + shared_examples 'valid authorization url' do + it 'should have a valid base URI' do + expect(uri).to match %r{https://accounts.google.com/o/oauth2/auth} + end + + it 'should request offline access' do + expect(URI(uri).query).to match(/access_type=offline/) + end + + it 'should request response type code' do + expect(URI(uri).query).to match(/response_type=code/) + end + + it 'should force approval' do + expect(URI(uri).query).to match(/approval_prompt=force/) + end + + it 'should include granted scopes' do + expect(URI(uri).query).to match(/include_granted_scopes=true/) + end + + it 'should include the correct client id' do + expect(URI(uri).query).to match(/client_id=testclient/) + end + + it 'should not include a client secret' do + expect(URI(uri).query).to_not match(/client_secret/) + end + + it 'should include the callback uri' do + expect(URI(uri).query).to match( + %r{redirect_uri=https://www.example.com/oauth/callback}) + end + + it 'should include the scope' do + expect(URI(uri).query).to match(/scope=email%20profile/) + end + end + + context 'when generating authorization URLs with user ID & state' do + let(:uri) do + authorizer.get_authorization_url(login_hint: 'user1', state: 'mystate') + end + + it_behaves_like 'valid authorization url' + + it 'includes a login hint' do + expect(URI(uri).query).to match(/login_hint=user1/) + end + + it 'includes the app state' do + expect(URI(uri).query).to match(/state=mystate/) + end + end + + context 'when generating authorization URLs with user ID and no state' do + let(:uri) { authorizer.get_authorization_url(login_hint: 'user1') } + + it_behaves_like 'valid authorization url' + + it 'includes a login hint' do + expect(URI(uri).query).to match(/login_hint=user1/) + end + + it 'does not include the state parameter' do + expect(URI(uri).query).to_not match(/state/) + end + end + + context 'when generating authorization URLs with no user ID and no state' do + let(:uri) { authorizer.get_authorization_url } + + it_behaves_like 'valid authorization url' + + it 'does not include the login hint parameter' do + expect(URI(uri).query).to_not match(/login_hint/) + end + + it 'does not include the state parameter' do + expect(URI(uri).query).to_not match(/state/) + end + end + + context 'when retrieving tokens' do + let(:token_json) do + MultiJson.dump( + access_token: 'accesstoken', + refresh_token: 'refreshtoken', + expiration_time_millis: 1_441_234_742_000) + end + + context 'with a valid user id' do + let(:credentials) do + token_store.store('user1', token_json) + authorizer.get_credentials('user1') + end + + it 'should return an instance of UserRefreshCredentials' do + expect(credentials).to be_instance_of( + Google::Auth::UserRefreshCredentials) + end + + it 'should return credentials with a valid refresh token' do + expect(credentials.refresh_token).to eq 'refreshtoken' + end + + it 'should return credentials with a valid access token' do + expect(credentials.access_token).to eq 'accesstoken' + end + + it 'should return credentials with a valid client ID' do + expect(credentials.client_id).to eq 'testclient' + end + + it 'should return credentials with a valid client secret' do + expect(credentials.client_secret).to eq 'notasecret' + end + + it 'should return credentials with a valid scope' do + expect(credentials.scope).to eq %w(email profile) + end + + it 'should return credentials with a valid expiration time' do + expect(credentials.expires_at).to eq Time.at(1_441_234_742) + end + end + + context 'with an invalid user id' do + it 'should return nil' do + expect(authorizer.get_credentials('notauser')).to be_nil + end + end + end + + context 'when saving tokens' do + let(:expiry) { Time.now.to_i } + let(:credentials) do + Google::Auth::UserRefreshCredentials.new( + client_id: client_id.id, + client_secret: client_id.secret, + scope: scope, + refresh_token: 'refreshtoken', + access_token: 'accesstoken', + expires_at: expiry + ) + end + + let(:token_json) do + authorizer.store_credentials('user1', credentials) + token_store.load('user1') + end + + it 'should persist in the token store' do + expect(token_json).to_not be_nil + end + + it 'should persist the refresh token' do + expect(MultiJson.load(token_json)['refresh_token']).to eq 'refreshtoken' + end + + it 'should persist the access token' do + expect(MultiJson.load(token_json)['access_token']).to eq 'accesstoken' + end + + it 'should persist the client id' do + expect(MultiJson.load(token_json)['client_id']).to eq 'testclient' + end + + it 'should persist the scope' do + expect(MultiJson.load(token_json)['scope']).to include('email', 'profile') + end + + it 'should persist the expiry as milliseconds' do + expected_expiry = expiry * 1000 + expect(MultiJson.load(token_json)['expiration_time_millis']).to eql( + expected_expiry) + end + end + + context 'with valid authorization code' do + let(:token_json) do + MultiJson.dump('access_token' => '1/abc123', + 'token_type' => 'Bearer', + 'expires_in' => 3600) + end + + before(:example) do + stub_request(:post, 'https://www.googleapis.com/oauth2/v3/token') + .to_return(body: token_json, status: 200, headers: { + 'Content-Type' => 'application/json' }) + end + + it 'should exchange a code for credentials' do + credentials = authorizer.get_credentials_from_code( + user_id: 'user1', code: 'code') + expect(credentials.access_token).to eq '1/abc123' + end + + it 'should not store credentials when get only requested' do + authorizer.get_credentials_from_code(user_id: 'user1', code: 'code') + expect(token_store.load('user1')).to be_nil + end + + it 'should store credentials when requested' do + authorizer.get_and_store_credentials_from_code( + user_id: 'user1', code: 'code') + expect(token_store.load('user1')).to_not be_nil + end + end + + context 'with invalid authorization code' do + before(:example) do + stub_request(:post, 'https://www.googleapis.com/oauth2/v3/token') + .to_return(status: 400) + end + + it 'should raise an authorization error' do + expect do + authorizer.get_credentials_from_code(user_id: 'user1', code: 'badcode') + end.to raise_error Signet::AuthorizationError + end + + it 'should not store credentials when exchange fails' do + expect do + authorizer.get_credentials_from_code(user_id: 'user1', code: 'badcode') + end.to raise_error Signet::AuthorizationError + expect(token_store.load('user1')).to be_nil + end + end + + context 'when reovking authorization' do + let(:token_json) do + MultiJson.dump( + access_token: 'accesstoken', + refresh_token: 'refreshtoken', + expiration_time_millis: 1_441_234_742_000) + end + + before(:example) do + token_store.store('user1', token_json) + stub_request( + :get, 'https://accounts.google.com/o/oauth2/revoke?token=refreshtoken') + .to_return(status: 200) + end + + it 'should revoke the grant' do + authorizer.revoke_authorization('user1') + expect(a_request( + :get, 'https://accounts.google.com/o/oauth2/revoke?token=refreshtoken')) + .to have_been_made + end + + it 'should remove the token from storage' do + authorizer.revoke_authorization('user1') + expect(token_store.load('user1')).to be_nil + end + end + + # TODO: - Test that tokens are monitored + # TODO - Test scope enforcement (auth if upgrade required) +end diff --git a/spec/googleauth/user_refresh_spec.rb b/spec/googleauth/user_refresh_spec.rb index 2ac4404..9b87b78 100644 --- a/spec/googleauth/user_refresh_spec.rb +++ b/spec/googleauth/user_refresh_spec.rb @@ -57,7 +57,7 @@ describe Google::Auth::UserRefreshCredentials do before(:example) do @key = OpenSSL::PKey::RSA.new(2048) - @client = UserRefreshCredentials.new( + @client = UserRefreshCredentials.make_creds( json_key_io: StringIO.new(cred_json_text), scope: 'https://www.googleapis.com/auth/userinfo.profile' ) @@ -142,10 +142,11 @@ describe Google::Auth::UserRefreshCredentials do ENV[CLIENT_SECRET_VAR] = cred_json[:client_secret] ENV[REFRESH_TOKEN_VAR] = cred_json[:refresh_token] ENV[ACCOUNT_TYPE_VAR] = cred_json[:type] - expect(@clz.from_env(@scope)).to_not be_nil - expect(subject.client_id).to eq(cred_json[:client_id]) - expect(subject.client_secret).to eq(cred_json[:client_secret]) - expect(subject.refresh_token).to eq(cred_json[:refresh_token]) + creds = @clz.from_env(@scope) + expect(creds).to_not be_nil + expect(creds.client_id).to eq(cred_json[:client_id]) + expect(creds.client_secret).to eq(cred_json[:client_secret]) + expect(creds.refresh_token).to eq(cred_json[:refresh_token]) end end @@ -227,4 +228,87 @@ describe Google::Auth::UserRefreshCredentials do end end end + + shared_examples 'revoked token' do + it 'should nil the refresh token' do + expect(@client.refresh_token).to be_nil + end + + it 'should nil the access token' do + expect(@client.access_token).to be_nil + end + + it 'should mark the token as expired' do + expect(@client.expired?).to be_truthy + end + end + + describe 'when revoking a refresh token' do + let(:stubs) do + Faraday::Adapter::Test::Stubs.new do |stub| + stub.get('/o/oauth2/revoke') do |env| + expect(env.params['token']).to eql 'refreshtoken' + [200] + end + end + end + + let(:connection) do + Faraday.new do |c| + c.adapter(:test, stubs) + end + end + + before(:example) do + @client.revoke!(connection: connection) + end + + it_behaves_like 'revoked token' + end + + describe 'when revoking an access token' do + let(:stubs) do + Faraday::Adapter::Test::Stubs.new do |stub| + stub.get('/o/oauth2/revoke') do |env| + expect(env.params['token']).to eql 'accesstoken' + [200] + end + end + end + + let(:connection) do + Faraday.new do |c| + c.adapter(:test, stubs) + end + end + + before(:example) do + @client.refresh_token = nil + @client.access_token = 'accesstoken' + @client.revoke!(connection: connection) + end + + it_behaves_like 'revoked token' + end + + describe 'when revoking an invalid token' do + let(:stubs) do + Faraday::Adapter::Test::Stubs.new do |stub| + stub.get('/o/oauth2/revoke') do |_env| + [400] + end + end + end + + let(:connection) do + Faraday.new do |c| + c.adapter(:test, stubs) + end + end + + it 'raises an authorization error' do + expect { @client.revoke!(connection: connection) }.to raise_error( + Signet::AuthorizationError) + end + end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 7388a8a..f989d46 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -46,6 +46,7 @@ require 'faraday' require 'rspec' require 'logging' require 'rspec/logging_helper' +require 'webmock/rspec' # Allow Faraday to support test stubs Faraday::Adapter.load_middleware(:test) @@ -55,4 +56,28 @@ Faraday::Adapter.load_middleware(:test) RSpec.configure do |config| include RSpec::LoggingHelper config.capture_log_messages + config.include WebMock::API +end + +module TestHelpers + include WebMock::API + include WebMock::Matchers +end + +class DummyTokenStore + def initialize + @tokens = {} + end + + def load(id) + @tokens[id] + end + + def store(id, token) + @tokens[id] = token + end + + def delete(id) + @tokens.delete(id) + end end