Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add PKCE to 3 Legged OAuth exchange #471

Merged
merged 7 commits into from
Feb 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the reason for storing the code_verifier in the session? I understand the code was stateless so state needs to be stored elsewhere.

In the example, does it make sense to illustrate where code_verifier is needed again?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the reason for storing the `code_verifier` in the session? I understand the code was stateless so state needs to be stored elsewhere.
Yes, the reason behind storing code_verifier in the session is to make sure we maintain the state and pass on the same code_verifier in the subsequent call. Either we store the code_verifier itself in the session or we make sure that we're using the same authorizer object for further call, is upto the user how they want to design it.

In the example, does it make sense to illustrate where `code_verifier` is needed again?
I have done that on Line #123. I can write a comment on top and explain it further if it's not clear. WDYT ?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm I think the disconnect for me was in this example, why have the step of storing it in the session, instead of just setting it directly

Is the code reading it from the session somewhere, or is the user supposed to read it from the session later?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm I think the disconnect for me was in this example, why have the step of storing it in the session, instead of just setting it directly

The code is a property of Authorizer. But we may create a new instance of the Authorizer for the second call and lose the code.

Is the code reading it from the session somewhere, or is the user supposed to read it from the session later?

User is supposed to store it in the session when we create it for the first time. And then retrieve it from the session and set in the Authorizer object.
So it goes like:

  1. Generate a new code ( if it's not already there in the session ) and store it in the session for future call.
    session[:code_verifier] ||= Google::Auth::WebUserAuthorizer.generate_code_verifier
  2. Assign it to Authorizer.
    authorizer = Google::Auth::WebUserAuthorizer.new(..,.., code_verifier: session[:code_verifier])
  3. authorizer passes on the code with additional_parameter when you call authorizer.get_credential() which happens here

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay got it, thank you!

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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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