Skip to content

Commit

Permalink
feat: add PKCE to 3 Legged OAuth exchange (#471)
Browse files Browse the repository at this point in the history
  • Loading branch information
bajajneha27 committed Feb 8, 2024
1 parent 73ecde1 commit 65bae29
Show file tree
Hide file tree
Showing 5 changed files with 120 additions and 3 deletions.
39 changes: 39 additions & 0 deletions README.md
Expand Up @@ -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.
Expand Down
48 changes: 47 additions & 1 deletion lib/googleauth/user_authorizer.rb
Expand Up @@ -16,6 +16,7 @@
require "multi_json"
require "googleauth/signet"
require "googleauth/user_refresh"
require "securerandom"

module Google
module Auth
Expand Down Expand Up @@ -57,14 +58,19 @@ 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?

@client_id = client_id
@scope = Array(scope)
@token_store = token_store
@callback_uri = callback_uri || "/oauth2callback"
@code_verifier = code_verifier
end

# Build the URL for requesting authorization.
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
8 changes: 6 additions & 2 deletions lib/googleauth/web_user_authorizer.rb
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions spec/googleauth/user_authorizer_spec.rb
Expand Up @@ -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"
Expand Down
7 changes: 7 additions & 0 deletions spec/googleauth/web_user_authorizer_spec.rb
Expand Up @@ -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
Expand Down

0 comments on commit 65bae29

Please sign in to comment.