diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 359c0c9..834f1f9 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -29,3 +29,4 @@ Metrics/MethodLength: Style/FormatString: Exclude: - 'lib/googleauth/user_authorizer.rb' + - 'lib/googleauth/web_user_authorizer.rb' diff --git a/Gemfile b/Gemfile index c53ac89..c1ba049 100755 --- a/Gemfile +++ b/Gemfile @@ -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 diff --git a/lib/googleauth.rb b/lib/googleauth.rb index 75294f1..e364813 100644 --- a/lib/googleauth.rb +++ b/lib/googleauth.rb @@ -36,6 +36,7 @@ require 'googleauth/service_account' 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 diff --git a/lib/googleauth/web_user_authorizer.rb b/lib/googleauth/web_user_authorizer.rb new file mode 100644 index 0000000..0accff7 --- /dev/null +++ b/lib/googleauth/web_user_authorizer.rb @@ -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] 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] 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] 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 diff --git a/spec/googleauth/client_id_spec.rb b/spec/googleauth/client_id_spec.rb index 3a45b54..c35c802 100644 --- a/spec/googleauth/client_id_spec.rb +++ b/spec/googleauth/client_id_spec.rb @@ -31,6 +31,7 @@ spec_dir = File.expand_path(File.join(File.dirname(__FILE__))) $LOAD_PATH.unshift(spec_dir) $LOAD_PATH.uniq! +require 'spec_helper' require 'fakefs/safe' require 'googleauth' diff --git a/spec/googleauth/web_user_authorizer_spec.rb b/spec/googleauth/web_user_authorizer_spec.rb new file mode 100644 index 0000000..b1030b7 --- /dev/null +++ b/spec/googleauth/web_user_authorizer_spec.rb @@ -0,0 +1,159 @@ +# 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/web_user_authorizer' +require 'uri' +require 'multi_json' +require 'spec_helper' +require 'rack' + +describe Google::Auth::WebUserAuthorizer do + include TestHelpers + + let(:client_id) { Google::Auth::ClientId.new('testclient', 'notasecret') } + let(:scope) { %w(email profile) } + let(:token_store) { DummyTokenStore.new } + let(:authorizer) do + Google::Auth::WebUserAuthorizer.new(client_id, scope, token_store) + end + + describe '#get_authorization_url' do + let(:env) do + Rack::MockRequest.env_for( + 'http://example.com:8080/test', + 'REMOTE_ADDR' => '10.10.10.10') + end + let(:request) { Rack::Request.new(env) } + it 'should include current url in state' do + url = authorizer.get_authorization_url(request: request) + expect(url).to match( + %r{%22current_uri%22:%22http://example.com:8080/test%22}) + end + + it 'should include request forgery token in state' do + expect(SecureRandom).to receive(:base64).and_return('aGVsbG8=') + url = authorizer.get_authorization_url(request: request) + expect(url).to match(/%22session_id%22:%22aGVsbG8=%22/) + end + + it 'should include request forgery token in session' do + expect(SecureRandom).to receive(:base64).and_return('aGVsbG8=') + authorizer.get_authorization_url(request: request) + expect(request.session['g-xsrf-token']).to eq 'aGVsbG8=' + end + + it 'should resolve callback against base URL' do + url = authorizer.get_authorization_url(request: request) + expect(url).to match( + %r{redirect_uri=http://example.com:8080/oauth2callback}) + end + + it 'should allow overriding the current URL' do + url = authorizer.get_authorization_url( + request: request, + redirect_to: '/foo') + expect(url).to match %r{%22current_uri%22:%22/foo%22} + end + + it 'should pass through login hint' do + url = authorizer.get_authorization_url( + request: request, + login_hint: 'user@example.com') + expect(url).to match(/login_hint=user@example.com/) + end + end + + shared_examples 'handles callback' 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 + + let(:env) do + Rack::MockRequest.env_for( + 'http://example.com:8080/oauth2callback?code=authcode&'\ + 'state=%7B%22current_uri%22%3A%22%2Ffoo%22%2C%22'\ + 'session_id%22%3A%22abc%22%7D', + 'REMOTE_ADDR' => '10.10.10.10') + end + let(:request) { Rack::Request.new(env) } + + before(:example) do + request.session['g-xsrf-token'] = 'abc' + end + + it 'should return credentials when valid code present' do + expect(credentials).to be_instance_of( + Google::Auth::UserRefreshCredentials) + end + + it 'should return next URL to redirect to' do + expect(next_url).to eq '/foo' + end + + it 'should fail if xrsf token in session and does not match request' do + request.session['g-xsrf-token'] = '123' + expect { credentials }.to raise_error(Signet::AuthorizationError) + end + end + + describe '#handle_auth_callback' do + let(:result) { authorizer.handle_auth_callback('user1', request) } + let(:credentials) { result[0] } + let(:next_url) { result[1] } + + it_behaves_like 'handles callback' + end + + describe '#handle_auth_callback_deferred and #get_credentials' do + let(:next_url) do + Google::Auth::WebUserAuthorizer.handle_auth_callback_deferred(request) + end + + let(:credentials) do + next_url + authorizer.get_credentials('user1', request) + end + + it_behaves_like 'handles callback' + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index f989d46..c63ee4c 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -47,6 +47,10 @@ require 'rspec' require 'logging' require 'rspec/logging_helper' require 'webmock/rspec' +require 'multi_json' + +# Preload adapter to work around Rubinius error with FakeFS +MultiJson.use(:json_gem) # Allow Faraday to support test stubs Faraday::Adapter.load_middleware(:test)