Base version of 3LO support.

This commit is contained in:
Steven Bazyl 2015-10-14 13:53:37 -07:00
parent 5cae4f7988
commit e903a12563
23 changed files with 1633 additions and 63 deletions

View File

@ -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'

18
Gemfile
View File

@ -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

View File

@ -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

View File

@ -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

102
lib/googleauth/client_id.rb Normal file
View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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<String>] 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<String>] 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>, 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<String>] 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<String>] 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

View File

@ -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>, 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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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