diff --git a/README.md b/README.md index 087b7bb..86cddde 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,45 @@ get('/oauth2callback') do end ``` +### Example (Web with PKCE) + +Proof Key for Code Exchange (PKCE) is an [RFC](https://www.rfc-editor.org/rfc/rfc7636) that aims to prevent malicious operating system processes from hijacking an OAUTH 2.0 exchange. PKCE mitigates the above vulnerability by including `code_challenge` and `code_challenge_method` parameters in the Authorization Request and a `code_verifier` parameter in the Access Token Request. + +```ruby +require 'googleauth' +require 'googleauth/web_user_authorizer' +require 'googleauth/stores/redis_token_store' +require 'redis' + +client_id = Google::Auth::ClientId.from_file('/path/to/client_secrets.json') +scope = ['https://www.googleapis.com/auth/drive'] +token_store = Google::Auth::Stores::RedisTokenStore.new(redis: Redis.new) +authorizer = Google::Auth::WebUserAuthorizer.new( + client_id, scope, token_store, '/oauth2callback') + + +get('/authorize') do + # NOTE: Assumes the user is already authenticated to the app + user_id = request.session['user_id'] + # User needs to take care of generating the code_verifier and storing it in + # the session. + request.session['code_verifier'] ||= Google::Auth::WebUserAuthorizer.generate_code_verifier + authorizer.code_verifier = request.session['code_verifier'] + credentials = authorizer.get_credentials(user_id, request) + if credentials.nil? + redirect authorizer.get_authorization_url(login_hint: user_id, request: request) + end + # Credentials are valid, can call APIs + # ... +end + +get('/oauth2callback') do + target_url = Google::Auth::WebUserAuthorizer.handle_auth_callback_deferred( + request) + redirect target_url +end +``` + ### Example (Command Line) [Deprecated] The Google Auth OOB flow has been discontiued on January 31, 2023. The OOB flow is a legacy flow that is no longer considered secure. To continue using Google Auth, please migrate your applications to a more secure flow. For more information on how to do this, please refer to this [OOB Migration](https://developers.google.com/identity/protocols/oauth2/resources/oob-migration) guide. diff --git a/lib/googleauth/user_authorizer.rb b/lib/googleauth/user_authorizer.rb index 9298817..e6948d5 100644 --- a/lib/googleauth/user_authorizer.rb +++ b/lib/googleauth/user_authorizer.rb @@ -16,6 +16,7 @@ require "multi_json" require "googleauth/signet" require "googleauth/user_refresh" +require "securerandom" module Google module Auth @@ -57,7 +58,11 @@ class UserAuthorizer # @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 + # @param [String] code_verifier + # Random string of 43-128 chars used to verify the key exchange using + # PKCE. + def initialize client_id, scope, token_store, + callback_uri = nil, code_verifier: nil raise NIL_CLIENT_ID_ERROR if client_id.nil? raise NIL_SCOPE_ERROR if scope.nil? @@ -65,6 +70,7 @@ def initialize client_id, scope, token_store, callback_uri = nil @scope = Array(scope) @token_store = token_store @callback_uri = callback_uri || "/oauth2callback" + @code_verifier = code_verifier end # Build the URL for requesting authorization. @@ -86,6 +92,18 @@ def initialize client_id, scope, token_store, callback_uri = nil # Authorization url def get_authorization_url options = {} scope = options[:scope] || @scope + + options[:additional_parameters] ||= {} + + if @code_verifier + options[:additional_parameters].merge!( + { + code_challenge: generate_code_challenge(@code_verifier), + code_challenge_method: code_challenge_method + } + ) + end + credentials = UserRefreshCredentials.new( client_id: @client_id.id, client_secret: @client_id.secret, @@ -157,6 +175,8 @@ def get_credentials_from_code options = {} code = options[:code] scope = options[:scope] || @scope base_url = options[:base_url] + options[:additional_parameters] ||= {} + options[:additional_parameters].merge!({ code_verifier: @code_verifier }) credentials = UserRefreshCredentials.new( client_id: @client_id.id, client_secret: @client_id.secret, @@ -228,6 +248,23 @@ def store_credentials user_id, credentials credentials end + # The code verifier for PKCE for OAuth 2.0. When set, the + # authorization URI will contain the Code Challenge and Code + # Challenge Method querystring parameters, and the token URI will + # contain the Code Verifier parameter. + # + # @param [String|nil] new_code_erifier + def code_verifier= new_code_verifier + @code_verifier = new_code_verifier + end + + # Generate the code verifier needed to be sent while fetching + # authorization URL. + def self.generate_code_verifier + random_number = rand 32..96 + SecureRandom.alphanumeric random_number + end + private # @private Fetch stored token with given user_id @@ -272,6 +309,15 @@ def redirect_uri_for base_url def uri_is_postmessage? uri uri.to_s.casecmp("postmessage").zero? end + + def generate_code_challenge code_verifier + digest = Digest::SHA256.digest code_verifier + Base64.urlsafe_encode64 digest, padding: false + end + + def code_challenge_method + "S256" + end end end end diff --git a/lib/googleauth/web_user_authorizer.rb b/lib/googleauth/web_user_authorizer.rb index 1c9f7b8..ddf9644 100644 --- a/lib/googleauth/web_user_authorizer.rb +++ b/lib/googleauth/web_user_authorizer.rb @@ -96,8 +96,12 @@ def self.handle_auth_callback_deferred request # @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 + # @param [String] code_verifier + # Random string of 43-128 chars used to verify the key exchange using + # PKCE. + def initialize client_id, scope, token_store, + callback_uri = nil, code_verifier: nil + super client_id, scope, token_store, callback_uri, code_verifier: code_verifier end # Handle the result of the oauth callback. Exchanges the authorization diff --git a/spec/googleauth/user_authorizer_spec.rb b/spec/googleauth/user_authorizer_spec.rb index 2cde324..2a176ae 100644 --- a/spec/googleauth/user_authorizer_spec.rb +++ b/spec/googleauth/user_authorizer_spec.rb @@ -98,6 +98,27 @@ end end + context "when generating authorization URLs and code_verifier is manually passed" do + let(:code_verifier) { "IeJRY4uem0581Lcw6CiZ3fNwngg" } + let :authorizer do + Google::Auth::UserAuthorizer.new(client_id, + scope, + token_store, + callback_uri, + code_verifier: code_verifier) + end + let :uri do + authorizer.get_authorization_url + end + + it_behaves_like "valid authorization url" + + it "should include code_challenge and code_challenge_method" do + expect(URI(uri).query).to match(/code_challenge=/) + expect(URI(uri).query).to match(/code_challenge_method=S256/) + end + end + context "when generating authorization URLs with user ID & state" do let :uri do authorizer.get_authorization_url login_hint: "user1", state: "mystate" diff --git a/spec/googleauth/web_user_authorizer_spec.rb b/spec/googleauth/web_user_authorizer_spec.rb index 98fc9d2..edcb14d 100644 --- a/spec/googleauth/web_user_authorizer_spec.rb +++ b/spec/googleauth/web_user_authorizer_spec.rb @@ -88,6 +88,13 @@ ) expect(url).to match(/login_hint=user@example.com/) end + + it "should include code_challenge and code_challenge_method" do + authorizer.code_verifier = Google::Auth::WebUserAuthorizer.generate_code_verifier + url = authorizer.get_authorization_url(request: request) + expect(url).to match(/code_challenge=/) + expect(url).to match(/code_challenge_method=S256/) + end end shared_examples "handles callback" do