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

Migrating to Google Auth Library #180

Closed
wants to merge 24 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
0236bf9
Update setup.py with new google-auth dependencies
junpeng-jp May 28, 2022
0b67559
Update threadsafe code following google recommendations
junpeng-jp May 28, 2022
799b005
Migration to google-auth library (#89)
junpeng-jp May 28, 2022
2a0a767
Implement lockfile storage (#89)
junpeng-jp May 28, 2022
9230969
Cleanup of oauth tests & remove service config read/write checks
junpeng-jp May 28, 2022
2a688e2
Add oauth test 10: google.auth.default auth
junpeng-jp May 28, 2022
a3bc162
Deprecate command line auth (#173)
junpeng-jp May 28, 2022
c9a0f90
Remove dependencies on oauth2client
junpeng-jp May 28, 2022
012f9ed
Changed mocker.spy to track calls to FileBackend's __init__
junpeng-jp Jun 1, 2022
015a84f
Add simulated token expiry test
junpeng-jp Jun 5, 2022
51af33c
Reverted DEFAULT_SETTINGS to a class static variable
junpeng-jp Jul 24, 2022
0b1c17b
Fixed typo in GoogleAuth __init__ documentation
junpeng-jp Jul 24, 2022
9ea5409
Removed non-essential improvements to settings.py
junpeng-jp Jul 24, 2022
d8c1571
Remove unnecessary edits to .yaml cred files
junpeng-jp Jul 24, 2022
35e9ae3
Update incorrect documentation for oauth Test 10
junpeng-jp Jul 24, 2022
badbd4f
Add back LoadAuth & thread_local HTTP
junpeng-jp Jul 24, 2022
08d067c
Remove Refresh method as it is now handled by google auth library
junpeng-jp Jul 24, 2022
2477cbe
Add back "Authentication successful" message
junpeng-jp Jul 24, 2022
91a4f63
Clean-up LocalWebserverAuth & added OSError documentation
junpeng-jp Jul 24, 2022
9a9af28
Allow trying of new ports for all OSErrors
junpeng-jp Jul 30, 2022
46ba0c1
Allow manual overwrite of self.http for API resources
junpeng-jp Jul 30, 2022
710eee1
Deprecate CommandLineAuth
junpeng-jp Jul 30, 2022
6b3e901
Added Thread Locking & updated credential write logic
junpeng-jp Jul 30, 2022
82a8960
Add threading import
junpeng-jp Jul 30, 2022
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
660 changes: 312 additions & 348 deletions pydrive2/auth.py

Large diffs are not rendered by default.

58 changes: 58 additions & 0 deletions pydrive2/auth_helpers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
_OLD_CLIENT_CONFIG_KEYS = frozenset(
(
"client_id",
"client_secret",
"auth_uri",
"token_uri",
"revoke_uri",
"redirect_uri",
)
)

_CLIENT_CONFIG_KEYS = frozenset(
(
"client_id",
"client_secret",
"auth_uri",
"token_uri",
"redirect_uris",
)
)


def verify_client_config(client_config, with_oauth_type=True):
"""Verifies that format of the client config
loaded from a Google-format client secrets file.
"""

oauth_type = None
config = client_config

if with_oauth_type:
if "web" in client_config:
oauth_type = "web"
config = config["web"]

elif "installed" in client_config:
oauth_type = "installed"
config = config["installed"]
else:
raise ValueError(
"Client secrets must be for a web or installed app"
)

# This is the older format of client config
if _OLD_CLIENT_CONFIG_KEYS.issubset(config.keys()):
config["redirect_uris"] = [config["redirect_uri"]]

# by default, the redirect uri is the first in the list
if "redirect_uri" not in config:
config["redirect_uri"] = config["redirect_uris"][0]

if "revoke_uri" not in config:
config["revoke_uri"] = "https://oauth2.googleapis.com/revoke"

if not _CLIENT_CONFIG_KEYS.issubset(config.keys()):
raise ValueError("Client secrets is not in the correct format.")

return oauth_type, config
2 changes: 1 addition & 1 deletion pydrive2/files.py
Original file line number Diff line number Diff line change
Expand Up @@ -599,7 +599,7 @@ def _WrapRequest(self, request):
Ensures thread safety. Similar to other places where we call
`.execute(http=self.http)` to pass a client from the thread local storage.
"""
if self.http:
if self.auth:
request.http = self.http
return request

Expand Down
1 change: 1 addition & 0 deletions pydrive2/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
"client_service_email": {"type": str, "required": False},
"client_pkcs12_file_path": {"type": str, "required": False},
"client_json_file_path": {"type": str, "required": False},
"use_default": {"type": bool, "required": False},
Copy link
Member

Choose a reason for hiding this comment

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

can we skip for the sake of this PR the default credentials auth? I think it should be done outside of the ServiceAuth + probably we should be using a different setting for this (don't know which one yet)

Copy link
Author

Choose a reason for hiding this comment

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

I'll shift this into a subsequent PR once we've migrated to new google auth library

},
},
"oauth_scope": {
Expand Down
114 changes: 114 additions & 0 deletions pydrive2/storage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import os
import json
import warnings
import threading


_SYM_LINK_MESSAGE = "File: {0}: Is a symbolic link."
_IS_DIR_MESSAGE = "{0}: Is a directory"
_MISSING_FILE_MESSAGE = "Cannot access {0}: No such file or directory"


def validate_file(filename):
if os.path.islink(filename):
raise IOError(_SYM_LINK_MESSAGE.format(filename))
elif os.path.isdir(filename):
raise IOError(_IS_DIR_MESSAGE.format(filename))
elif not os.path.isfile(filename):
warnings.warn(_MISSING_FILE_MESSAGE.format(filename))


class CredentialBackend(object):
"""Adapter that provides a consistent interface to read and write credential files"""

def _read_credentials(self, rpath):
"""Specific implementation of how the storage object should retrieve a file."""
return NotImplementedError

def _store_credentials(self, credential, rpath):
"""Specific implementation of how the storage object should write a file"""
return NotImplementedError

def _delete_credentials(self, rpath):
"""Specific implementation of how the storage object should delete a file."""
return NotImplementedError

def read_credentials(self, rpath):
"""Reads a credential config file and returns the config as a dictionary
:param fname: host name of the local web server.
:type host_name: str.`
:return: A credential file
"""
return self._read_credentials(rpath)

def store_credentials(self, credential, rpath):
"""Write a credential to
The Storage lock must be held when this is called.
Args:
credentials: Credentials, the credentials to store.
"""
self._store_credentials(credential, rpath)

def delete_credentials(self, rpath):
"""Delete credential.
Frees any resources associated with storing the credential.
The Storage lock must *not* be held when this is called.

Returns:
None
"""
self._delete_credentials(rpath)


class FileBackend(CredentialBackend):
"""Read and write credentials to a file backend with Thread-locking"""

def __init__(self):
self._thread_lock = threading.Lock()

def _create_file_if_needed(self, rpath):
"""Create an empty file if necessary.
This method will not initialize the file. Instead it implements a
simple version of "touch" to ensure the file has been created.
"""
if not os.path.exists(rpath):
old_umask = os.umask(0o177)
try:
open(rpath, "a+b").close()
finally:
os.umask(old_umask)

def _read_credentials(self, rpath):
"""Reads a local json file and parses the information into a info dictionary.
Returns:
Raises:
"""
with self._thread_lock:
validate_file(rpath)
with open(rpath, "r") as json_file:
return json.load(json_file)

def _store_credentials(self, credentials, rpath):
"""Writes current credentials to a local json file.
Args:
Raises:
"""
with self._thread_lock:
# write new credentials to the temp file
dirname, filename = os.path.split(rpath)
temp_path = os.path.join(dirname, "temp_{}".format(filename))
self._create_file_if_needed(temp_path)

with open(temp_path, "w") as json_file:
json_file.write(credentials.to_json())

# replace the existing credential file
os.replace(temp_path, rpath)

def _delete_credentials(self, rpath):
"""Delete Credentials file.
Args:
credentials: Credentials, the credentials to store.
"""
with self._thread_lock:
os.unlink(rpath)
11 changes: 11 additions & 0 deletions pydrive2/test/settings/default_user.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
client_config_backend: file
client_config_file: tmp/pydrive2/user.json

save_credentials: True
save_credentials_backend: file
save_credentials_file: credentials/default_user.dat

oauth_scope:
- https://www.googleapis.com/auth/drive

get_refresh_token: True
9 changes: 9 additions & 0 deletions pydrive2/test/settings/default_user_no_refresh.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
client_config_backend: file
client_config_file: tmp/pydrive2/user.json

save_credentials: True
save_credentials_backend: file
save_credentials_file: credentials/default_user_no_refresh.dat

oauth_scope:
- https://www.googleapis.com/auth/drive
5 changes: 5 additions & 0 deletions pydrive2/test/settings/test_oauth_test_10.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
client_config_backend: service
service_config:
use_default: True
oauth_scope:
- https://www.googleapis.com/auth/drive
26 changes: 18 additions & 8 deletions pydrive2/test/test_oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
delete_file,
settings_file_path,
)
from oauth2client.file import Storage
from ..storage import FileBackend


def setup_module(module):
Expand All @@ -22,6 +22,7 @@ def test_01_LocalWebserverAuthWithClientConfigFromFile():
# Test if authentication works with config read from file
ga = GoogleAuth(settings_file_path("test_oauth_test_01.yaml"))
ga.LocalWebserverAuth()
assert ga.credentials
assert not ga.access_token_expired
# Test if correct credentials file is created
CheckCredentialsFile("credentials/1.dat")
Expand All @@ -35,6 +36,7 @@ def test_02_LocalWebserverAuthWithClientConfigFromSettings():
# Test if authentication works with config read from settings
ga = GoogleAuth(settings_file_path("test_oauth_test_02.yaml"))
ga.LocalWebserverAuth()
assert ga.credentials
assert not ga.access_token_expired
# Test if correct credentials file is created
CheckCredentialsFile("credentials/2.dat")
Expand All @@ -48,6 +50,7 @@ def test_03_LocalWebServerAuthWithNoCredentialsSaving():
ga = GoogleAuth(settings_file_path("test_oauth_test_03.yaml"))
assert not ga.settings["save_credentials"]
ga.LocalWebserverAuth()
assert ga.credentials
assert not ga.access_token_expired
time.sleep(1)

Expand All @@ -59,6 +62,7 @@ def test_04_CommandLineAuthWithClientConfigFromFile():
# Test if authentication works with config read from file
ga = GoogleAuth(settings_file_path("test_oauth_test_04.yaml"))
ga.CommandLineAuth()
assert ga.credentials
assert not ga.access_token_expired
# Test if correct credentials file is created
CheckCredentialsFile("credentials/4.dat")
Expand All @@ -70,6 +74,7 @@ def test_05_ConfigFromSettingsWithoutOauthScope():
# Test if authentication works without oauth_scope
ga = GoogleAuth(settings_file_path("test_oauth_test_05.yaml"))
ga.LocalWebserverAuth()
assert ga.credentials
assert not ga.access_token_expired
time.sleep(1)

Expand All @@ -79,6 +84,7 @@ def test_06_ServiceAuthFromSavedCredentialsP12File():
setup_credentials("credentials/6.dat")
ga = GoogleAuth(settings_file_path("test_oauth_test_06.yaml"))
ga.ServiceAuth()
assert ga.credentials
assert not ga.access_token_expired
time.sleep(1)

Expand All @@ -91,12 +97,9 @@ def test_07_ServiceAuthFromSavedCredentialsJsonFile():
delete_file(credentials_file)
assert not os.path.exists(credentials_file)
ga.ServiceAuth()
assert os.path.exists(credentials_file)
# Secondary auth should be made only using the previously saved
# login info
ga = GoogleAuth(settings_file_path("test_oauth_test_07.yaml"))
ga.ServiceAuth()
assert not ga.access_token_expired
assert ga.credentials
time.sleep(1)


Expand All @@ -118,13 +121,20 @@ def test_09_SaveLoadCredentialsUsesDefaultStorage(mocker):
# Delete old credentials file
delete_file(credentials_file)
assert not os.path.exists(credentials_file)
spy = mocker.spy(Storage, "__init__")
spy = mocker.spy(FileBackend, "__init__")
ga.ServiceAuth()
ga.LoadCredentials()
ga.SaveCredentials()
assert spy.call_count == 0


def test_10_ServiceAuthFromEnvironmentDefault():
# Test Google's default authentication
# uses GOOGLE_APPLICATION_CREDENTIALS env variable as service.yaml path
ga = GoogleAuth(settings_file_path("test_oauth_test_10.yaml"))
ga.ServiceAuth()
assert ga.credentials
time.sleep(1)


def CheckCredentialsFile(credentials, no_file=False):
ga = GoogleAuth(settings_file_path("test_oauth_default.yaml"))
ga.LoadCredentialsFile(credentials)
Expand Down
77 changes: 77 additions & 0 deletions pydrive2/test/test_token_expiry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import pytest
from pydrive2.auth import GoogleAuth
from pydrive2.drive import GoogleDrive
from pydrive2.test.test_util import (
settings_file_path,
setup_credentials,
pydrive_retry,
delete_file,
)
from google.auth.exceptions import RefreshError


@pytest.fixture
def googleauth_refresh():
setup_credentials()
# Delete old credentials file
delete_file("credentials/default_user.dat")
ga = GoogleAuth(settings_file_path("default_user.yaml"))
ga.LocalWebserverAuth()

return ga


@pytest.fixture
def googleauth_no_refresh():
setup_credentials()
# Delete old credentials file
delete_file("credentials/default_user_no_refresh.dat")
ga = GoogleAuth(settings_file_path("default_user_no_refresh.yaml"))
ga.LocalWebserverAuth()

return ga


@pytest.mark.manual
def test_01_TokenExpiryWithRefreshToken(googleauth_refresh):
gdrive = GoogleDrive(googleauth_refresh)

about_object = pydrive_retry(gdrive.GetAbout)
assert about_object is not None

# save the first access token for comparison
token1 = gdrive.auth.credentials.token

# simulate token expiry by deleting the underlying token
gdrive.auth.credentials.token = None

# credential object should still exist but access token expired
assert gdrive.auth.credentials
assert gdrive.auth.access_token_expired

about_object = pydrive_retry(gdrive.GetAbout)
assert about_object is not None

# save the second access token for comparison
token2 = gdrive.auth.credentials.token

assert token1 != token2


@pytest.mark.manual
def test_02_TokenExpiryWithoutRefreshToken(googleauth_no_refresh):
gdrive = GoogleDrive(googleauth_no_refresh)

about_object = pydrive_retry(gdrive.GetAbout)
assert about_object is not None

# simulate token expiry by deleting the underlying token
gdrive.auth.credentials.token = None

# credential object should still exist but access token expired
assert gdrive.auth.credentials
assert gdrive.auth.access_token_expired

# as credentials have no refresh token, this would fail
with pytest.raises(RefreshError) as e_info:
about_object = pydrive_retry(gdrive.GetAbout)
Loading