From 4ce195f04b738beeb56c37589d5a8b479856ad68 Mon Sep 17 00:00:00 2001 From: bajajnehaa Date: Tue, 12 Sep 2023 08:10:42 +0000 Subject: [PATCH] feat: add PKCE to 3LO exchange --- lib/googleauth/user_authorizer.rb | 50 ++++++++++++++++++++- lib/googleauth/web_user_authorizer.rb | 8 +++- spec/googleauth/user_authorizer_spec.rb | 21 +++++++++ spec/googleauth/web_user_authorizer_spec.rb | 6 +++ 4 files changed, 82 insertions(+), 3 deletions(-) diff --git a/lib/googleauth/user_authorizer.rb b/lib/googleauth/user_authorizer.rb index 92988176..4ecb6555 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. Auto-generated if not provided + 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: get_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,25 @@ def store_credentials user_id, credentials credentials end + # A cryptographically random string that is used to correlate the + # authorization request to the token request. + # 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 generate_code_verifier + random_number = rand 32..96 + res = SecureRandom.alphanumeric(random_number).to_str + res + end + private # @private Fetch stored token with given user_id @@ -272,6 +311,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 get_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 1c9f7b8b..65378e74 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. Auto-generated if not provided. + def initialize client_id, scope, token_store, + callback_uri = nil, code_verifier = nil + super client_id, scope, token_store, callback_uri, 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 2cde3245..e2eb8cec 100644 --- a/spec/googleauth/user_authorizer_spec.rb +++ b/spec/googleauth/user_authorizer_spec.rb @@ -77,6 +77,11 @@ it "should include the scope" do expect(URI(uri).query).to match(/scope=email%20profile/) end + + 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 and callback_uri is 'postmessage'" do @@ -98,6 +103,22 @@ 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) + end + let :uri do + authorizer.get_authorization_url + end + + it_behaves_like "valid authorization url" + 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 98fc9d26..3f7da3aa 100644 --- a/spec/googleauth/web_user_authorizer_spec.rb +++ b/spec/googleauth/web_user_authorizer_spec.rb @@ -88,6 +88,12 @@ ) expect(url).to match(/login_hint=user@example.com/) end + + it "should include code_challenge and code_challenge_method" do + 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