Skip to content

Commit

Permalink
Merge pull request #51 from sqrrrl/master
Browse files Browse the repository at this point in the history
Round 2 of 3LO support - web flow
  • Loading branch information
tbetbetbe committed Nov 12, 2015
2 parents 1e92446 + 8ac3dcf commit 2be5bfe
Show file tree
Hide file tree
Showing 7 changed files with 455 additions and 0 deletions.
1 change: 1 addition & 0 deletions .rubocop_todo.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,4 @@ Metrics/MethodLength:
Style/FormatString:
Exclude:
- 'lib/googleauth/user_authorizer.rb'
- 'lib/googleauth/web_user_authorizer.rb'
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ group :development do
gem 'redis', '~> 3.2'
gem 'fakeredis', '~> 0.5'
gem 'webmock', '~> 1.21'
gem 'rack-test', '~> 0.6'
end

platforms :jruby do
Expand Down
1 change: 1 addition & 0 deletions lib/googleauth.rb
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
require 'googleauth/user_refresh'
require 'googleauth/client_id'
require 'googleauth/user_authorizer'
require 'googleauth/web_user_authorizer'

module Google
# Module Auth provides classes that provide Google-specific authorization
Expand Down
288 changes: 288 additions & 0 deletions lib/googleauth/web_user_authorizer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,288 @@
# 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'
require 'googleauth/signet'
require 'googleauth/user_authorizer'
require 'googleauth/user_refresh'
require 'securerandom'

module Google
module Auth
# Varation on {Google::Auth::UserAuthorizer} adapted for Rack based
# web applications.
#
# Example usage:
#
# get('/') do
# user_id = request.session['user_email']
# credentials = authorizer.get_credentials(user_id, request)
# if credentials.nil?
# redirect authorizer.get_redirect_uri(user_id, request)
# end
# # Credentials are valid, can call APIs
# ...
# end
#
# get('/oauth2callback') do
# user_id = request.session['user_email']
# _, return_uri = authorizer.handle_auth_callback(user_id, request)
# redirect return_uri
# end
#
# Instead of implementing the callback directly, applications are
# encouraged to use {Google::Auth::Web::AuthCallbackApp} instead.
#
# For rails apps, see {Google::Auth::ControllerHelpers}
#
# @see {Google::Auth::AuthCallbackApp}
# @see {Google::Auth::ControllerHelpers}
# @note Requires sessions are enabled
class WebUserAuthorizer < Google::Auth::UserAuthorizer
STATE_PARAM = 'state'
AUTH_CODE_KEY = 'code'
ERROR_CODE_KEY = 'error'
SESSION_ID_KEY = 'session_id'
CALLBACK_STATE_KEY = 'g-auth-callback'
CURRENT_URI_KEY = 'current_uri'
XSRF_KEY = 'g-xsrf-token'
SCOPE_KEY = 'scope'

NIL_REQUEST_ERROR = 'Request is required.'
NIL_SESSION_ERROR = 'Sessions must be enabled'
MISSING_AUTH_CODE_ERROR = 'Missing authorization code in request'
AUTHORIZATION_ERROR = 'Authorization error: %s'
INVALID_STATE_TOKEN_ERROR = 'State token does not match expected value'

class << self
attr_accessor :default
end

# Handle the result of the oauth callback. This version defers the
# exchange of the code by temporarily stashing the results in the user's
# session. This allows apps to use the generic
# {Google::Auth::WebUserAuthorizer::CallbackApp} handler for the callback
# without any additional customization.
#
# Apps that wish to handle the callback directly should use
# {#handle_auth_callback} instead.
#
# @param [Rack::Request] request
# Current request
def self.handle_auth_callback_deferred(request)
callback_state, redirect_uri = extract_callback_state(request)
request.session[CALLBACK_STATE_KEY] = MultiJson.dump(callback_state)
redirect_uri
end

# 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)
super(client_id, scope, token_store, callback_uri)
end

# Handle the result of the oauth callback. Exchanges the authorization
# code from the request and persists to storage.
#
# @param [String] user_id
# Unique ID of the user for loading/storing credentials.
# @param [Rack::Request] request
# Current request
# @return (Google::Auth::UserRefreshCredentials, String)
# credentials & next URL to redirect to
def handle_auth_callback(user_id, request)
callback_state, redirect_uri = WebUserAuthorizer.extract_callback_state(
request)
WebUserAuthorizer.validate_callback_state(callback_state, request)
credentials = get_and_store_credentials_from_code(
user_id: user_id,
code: callback_state[AUTH_CODE_KEY],
scope: callback_state[SCOPE_KEY],
base_url: request.url)
[credentials, redirect_uri]
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 [Rack::Request] request
# Current request
# @param [String] redirect_to
# Optional URL to proceed to after authorization complete. Defaults to
# the current URL.
# @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 = {})
options = options.dup
request = options[:request]
fail NIL_REQUEST_ERROR if request.nil?
fail NIL_SESSION_ERROR if request.session.nil?

redirect_to = options[:redirect_to] || request.url
request.session[XSRF_KEY] = SecureRandom.base64
options[:state] = MultiJson.dump(
SESSION_ID_KEY => request.session[XSRF_KEY],
CURRENT_URI_KEY => redirect_to)
options[:base_url] = request.url
super(options)
end

# Fetch stored credentials for the user.
#
# @param [String] user_id
# Unique ID of the user for loading/storing credentials.
# @param [Rack::Request] request
# Current request
# @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
# @raise [Signet::AuthorizationError]
# May raise an error if an authorization code is present in the session
# and exchange of the code fails
def get_credentials(user_id, request, scope = nil)
if request.session.key?(CALLBACK_STATE_KEY)
# Note - in theory, no need to check required scope as this is
# expected to be called immediately after a return from authorization
state_json = request.session.delete(CALLBACK_STATE_KEY)
callback_state = MultiJson.load(state_json)
WebUserAuthorizer.validate_callback_state(callback_state, request)
get_and_store_credentials_from_code(
user_id: user_id,
code: callback_state[AUTH_CODE_KEY],
scope: callback_state[SCOPE_KEY],
base_url: request.url)
else
super(user_id, scope)
end
end

def self.extract_callback_state(request)
state = MultiJson.load(request[STATE_PARAM] || '{}')
redirect_uri = state[CURRENT_URI_KEY]
callback_state = {
AUTH_CODE_KEY => request[AUTH_CODE_KEY],
ERROR_CODE_KEY => request[ERROR_CODE_KEY],
SESSION_ID_KEY => state[SESSION_ID_KEY],
SCOPE_KEY => request[SCOPE_KEY]
}
[callback_state, redirect_uri]
end

# Verifies the results of an authorization callback
#
# @param [Hash] state
# Callback state
# @option state [String] AUTH_CODE_KEY
# The authorization code
# @option state [String] ERROR_CODE_KEY
# Error message if failed
# @param [Rack::Request] request
# Current request
def self.validate_callback_state(state, request)
if state[AUTH_CODE_KEY].nil?
fail Signet::AuthorizationError, MISSING_AUTH_CODE_ERROR
elsif state[ERROR_CODE_KEY]
fail Signet::AuthorizationError,
sprintf(AUTHORIZATION_ERROR, state[ERROR_CODE_KEY])
elsif request.session[XSRF_KEY] != state[SESSION_ID_KEY]
fail Signet::AuthorizationError, INVALID_STATE_TOKEN_ERROR
end
end

# Small Rack app which acts as the default callback handler for the app.
#
# To configure in Rails, add to routes.rb:
#
# match '/oauth2callback',
# to: Google::Auth::WebUserAuthorizer::CallbackApp,
# via: :all
#
# With Rackup, add to config.ru:
#
# map '/oauth2callback' do
# run Google::Auth::WebUserAuthorizer::CallbackApp
# end
#
# Or in a classic Sinatra app:
#
# get('/oauth2callback') do
# Google::Auth::WebUserAuthorizer::CallbackApp.call(env)
# end
#
# @see {Google::Auth::WebUserAuthorizer}
class CallbackApp
LOCATION_HEADER = 'Location'
REDIR_STATUS = 302
ERROR_STATUS = 500

# Handle a rack request. Simply stores the results the authorization
# in the session temporarily and redirects back to to the previously
# saved redirect URL. Credentials can be later retrieved by calling.
# {Google::Auth::Web::WebUserAuthorizer#get_credentials}
#
# See {Google::Auth::Web::WebUserAuthorizer#get_authorization_uri}
# for how to initiate authorization requests.
#
# @param [Hash] env
# Rack environment
# @return [Array]
# HTTP response
def self.call(env)
request = Rack::Request.new(env)
return_url = WebUserAuthorizer.handle_auth_callback_deferred(request)
if return_url
[REDIR_STATUS, { LOCATION_HEADER => return_url }, []]
else
[ERROR_STATUS, {}, ['No return URL is present in the request.']]
end
end

def call(env)
self.class.call(env)
end
end
end
end
end
1 change: 1 addition & 0 deletions spec/googleauth/client_id_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
$LOAD_PATH.unshift(spec_dir)
$LOAD_PATH.uniq!

require 'spec_helper'
require 'fakefs/safe'
require 'googleauth'

Expand Down
Loading

0 comments on commit 2be5bfe

Please sign in to comment.