From bfb1f8cc8a706ce5ca2a14886c920ca2220ec349 Mon Sep 17 00:00:00 2001 From: patkasper <45458434+patkasper@users.noreply.github.com> Date: Thu, 5 Dec 2019 22:03:44 +0100 Subject: [PATCH 1/4] feat: add `to_json` method to google.oauth2.credentials.Credentials (#367) --- google/oauth2/credentials.py | 30 ++++++++++++++++++++++++++++++ tests/oauth2/test_credentials.py | 24 ++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/google/oauth2/credentials.py b/google/oauth2/credentials.py index 676a4324e..3a32c0631 100644 --- a/google/oauth2/credentials.py +++ b/google/oauth2/credentials.py @@ -223,3 +223,33 @@ def from_authorized_user_file(cls, filename, scopes=None): with io.open(filename, "r", encoding="utf-8") as json_file: data = json.load(json_file) return cls.from_authorized_user_info(data, scopes) + + def to_json(self, strip=None): + """Utility function that creates a JSON representation of a Credentials + object. + + Args: + strip (Sequence[str]): Optional list of members to exclude from the + generated JSON. + + Returns: + str: A JSON representation of this instance, suitable to pass to + from_json(). + """ + prep = { + "token": self.token, + "refresh_token": self.refresh_token, + "token_uri": self.token_uri, + "client_id": self.client_id, + "client_secret": self.client_secret, + "scopes": self.scopes, + } + + # Remove empty entries + prep = {k: v for k, v in prep.items() if v is not None} + + # Remove entries that explicitely need to be removed + if strip is not None: + prep = {k: v for k, v in prep.items() if k not in strip} + + return json.dumps(prep) diff --git a/tests/oauth2/test_credentials.py b/tests/oauth2/test_credentials.py index 8bfdd7e0a..bb70f1516 100644 --- a/tests/oauth2/test_credentials.py +++ b/tests/oauth2/test_credentials.py @@ -331,3 +331,27 @@ def test_from_authorized_user_file(self): assert creds.refresh_token == info["refresh_token"] assert creds.token_uri == credentials._GOOGLE_OAUTH2_TOKEN_ENDPOINT assert creds.scopes == scopes + + def test_to_json(self): + info = AUTH_USER_INFO.copy() + creds = credentials.Credentials.from_authorized_user_info(info) + + # Test with no `strip` arg + json_output = creds.to_json() + json_asdict = json.loads(json_output) + assert json_asdict.get("token") == creds.token + assert json_asdict.get("refresh_token") == creds.refresh_token + assert json_asdict.get("token_uri") == creds.token_uri + assert json_asdict.get("client_id") == creds.client_id + assert json_asdict.get("scopes") == creds.scopes + assert json_asdict.get("client_secret") == creds.client_secret + + # Test with a `strip` arg + json_output = creds.to_json(strip=["client_secret"]) + json_asdict = json.loads(json_output) + assert json_asdict.get("token") == creds.token + assert json_asdict.get("refresh_token") == creds.refresh_token + assert json_asdict.get("token_uri") == creds.token_uri + assert json_asdict.get("client_id") == creds.client_id + assert json_asdict.get("scopes") == creds.scopes + assert json_asdict.get("client_secret") is None From ab3dc1e26f5240ea3456de364c7c5cb8f40f9583 Mon Sep 17 00:00:00 2001 From: Bu Sun Kim <8822365+busunkim96@users.noreply.github.com> Date: Fri, 6 Dec 2019 15:56:24 -0500 Subject: [PATCH 2/4] feat: send quota project id in x-goog-user-project header for OAuth2 credentials (#400) When the 3LO credentials are used, the quota project ("quota_project_id") is sent on every outgoing request in the x-goog-user-project HTTP header/grpc metadata. The quota project is used for billing and quota purposes. --- docs/reference/google.auth.crypt.base.rst | 7 ++++++ docs/reference/google.auth.crypt.rsa.rst | 7 ++++++ google/oauth2/credentials.py | 19 ++++++++++++++++ tests/oauth2/test_credentials.py | 27 +++++++++++++++++++++++ 4 files changed, 60 insertions(+) create mode 100644 docs/reference/google.auth.crypt.base.rst create mode 100644 docs/reference/google.auth.crypt.rsa.rst diff --git a/docs/reference/google.auth.crypt.base.rst b/docs/reference/google.auth.crypt.base.rst new file mode 100644 index 000000000..a8996501a --- /dev/null +++ b/docs/reference/google.auth.crypt.base.rst @@ -0,0 +1,7 @@ +google.auth.crypt.base module +============================= + +.. automodule:: google.auth.crypt.base + :members: + :inherited-members: + :show-inheritance: diff --git a/docs/reference/google.auth.crypt.rsa.rst b/docs/reference/google.auth.crypt.rsa.rst new file mode 100644 index 000000000..7060b03c8 --- /dev/null +++ b/docs/reference/google.auth.crypt.rsa.rst @@ -0,0 +1,7 @@ +google.auth.crypt.rsa module +============================ + +.. automodule:: google.auth.crypt.rsa + :members: + :inherited-members: + :show-inheritance: diff --git a/google/oauth2/credentials.py b/google/oauth2/credentials.py index 3a32c0631..422c8ab10 100644 --- a/google/oauth2/credentials.py +++ b/google/oauth2/credentials.py @@ -58,6 +58,7 @@ def __init__( client_id=None, client_secret=None, scopes=None, + quota_project_id=None, ): """ Args: @@ -81,6 +82,9 @@ def __init__( token if refresh information is provided (e.g. The refresh token scopes are a superset of this or contain a wild card scope like 'https://www.googleapis.com/auth/any-api'). + quota_project_id (Optional[str]): The project ID used for quota and billing. + This project may be different from the project used to + create the credentials. """ super(Credentials, self).__init__() self.token = token @@ -90,6 +94,7 @@ def __init__( self._token_uri = token_uri self._client_id = client_id self._client_secret = client_secret + self._quota_project_id = quota_project_id @property def refresh_token(self): @@ -123,6 +128,11 @@ def client_secret(self): """Optional[str]: The OAuth 2.0 client secret.""" return self._client_secret + @property + def quota_project_id(self): + """Optional[str]: The project to use for quota and billing purposes.""" + return self._quota_project_id + @property def requires_scopes(self): """False: OAuth 2.0 credentials have their scopes set when @@ -169,6 +179,12 @@ def refresh(self, request): ) ) + @_helpers.copy_docstring(credentials.Credentials) + def apply(self, headers, token=None): + super(Credentials, self).apply(headers, token=token) + if self.quota_project_id is not None: + headers["x-goog-user-project"] = self.quota_project_id + @classmethod def from_authorized_user_info(cls, info, scopes=None): """Creates a Credentials instance from parsed authorized user info. @@ -202,6 +218,9 @@ def from_authorized_user_info(cls, info, scopes=None): scopes=scopes, client_id=info["client_id"], client_secret=info["client_secret"], + quota_project_id=info.get( + "quota_project_id" + ), # quota project may not exist ) @classmethod diff --git a/tests/oauth2/test_credentials.py b/tests/oauth2/test_credentials.py index bb70f1516..59031d7de 100644 --- a/tests/oauth2/test_credentials.py +++ b/tests/oauth2/test_credentials.py @@ -294,6 +294,33 @@ def test_credentials_with_scopes_refresh_failure_raises_refresh_error( # expired.) assert creds.valid + def test_apply_with_quota_project_id(self): + creds = credentials.Credentials( + token="token", + refresh_token=self.REFRESH_TOKEN, + token_uri=self.TOKEN_URI, + client_id=self.CLIENT_ID, + client_secret=self.CLIENT_SECRET, + quota_project_id="quota-project-123", + ) + + headers = {} + creds.apply(headers) + assert headers["x-goog-user-project"] == "quota-project-123" + + def test_apply_with_no_quota_project_id(self): + creds = credentials.Credentials( + token="token", + refresh_token=self.REFRESH_TOKEN, + token_uri=self.TOKEN_URI, + client_id=self.CLIENT_ID, + client_secret=self.CLIENT_SECRET, + ) + + headers = {} + creds.apply(headers) + assert "x-goog-user-project" not in headers + def test_from_authorized_user_info(self): info = AUTH_USER_INFO.copy() From 381dd400911d29926ffbf04e0f2ba53ef7bb997e Mon Sep 17 00:00:00 2001 From: Peter Lamut Date: Mon, 9 Dec 2019 21:25:37 +0100 Subject: [PATCH 3/4] feat: add timeout to AuthorizedSession.request() (#397) --- google/auth/transport/requests.py | 85 +++++++++++++++++++--- noxfile.py | 1 + tests/transport/test_requests.py | 115 +++++++++++++++++++++++++++++- 3 files changed, 189 insertions(+), 12 deletions(-) diff --git a/google/auth/transport/requests.py b/google/auth/transport/requests.py index d1971cd88..f21c524da 100644 --- a/google/auth/transport/requests.py +++ b/google/auth/transport/requests.py @@ -18,6 +18,7 @@ import functools import logging +import time try: import requests @@ -64,6 +65,33 @@ def data(self): return self._response.content +class TimeoutGuard(object): + """A context manager raising an error if the suite execution took too long. + """ + + def __init__(self, timeout, timeout_error_type=requests.exceptions.Timeout): + self._timeout = timeout + self.remaining_timeout = timeout + self._timeout_error_type = timeout_error_type + + def __enter__(self): + self._start = time.time() + return self + + def __exit__(self, exc_type, exc_value, traceback): + if exc_value: + return # let the error bubble up automatically + + if self._timeout is None: + return # nothing to do, the timeout was not specified + + elapsed = time.time() - self._start + self.remaining_timeout = self._timeout - elapsed + + if self.remaining_timeout <= 0: + raise self._timeout_error_type() + + class Request(transport.Request): """Requests request adapter. @@ -193,8 +221,12 @@ def __init__( # credentials.refresh). self._auth_request = auth_request - def request(self, method, url, data=None, headers=None, **kwargs): - """Implementation of Requests' request.""" + def request(self, method, url, data=None, headers=None, timeout=None, **kwargs): + """Implementation of Requests' request. + + The ``timeout`` argument is interpreted as the approximate total time + of **all** requests that are made under the hood. + """ # pylint: disable=arguments-differ # Requests has a ton of arguments to request, but only two # (method, url) are required. We pass through all of the other @@ -208,13 +240,28 @@ def request(self, method, url, data=None, headers=None, **kwargs): # and we want to pass the original headers if we recurse. request_headers = headers.copy() if headers is not None else {} - self.credentials.before_request( - self._auth_request, method, url, request_headers + # Do not apply the timeout unconditionally in order to not override the + # _auth_request's default timeout. + auth_request = ( + self._auth_request + if timeout is None + else functools.partial(self._auth_request, timeout=timeout) ) - response = super(AuthorizedSession, self).request( - method, url, data=data, headers=request_headers, **kwargs - ) + with TimeoutGuard(timeout) as guard: + self.credentials.before_request(auth_request, method, url, request_headers) + timeout = guard.remaining_timeout + + with TimeoutGuard(timeout) as guard: + response = super(AuthorizedSession, self).request( + method, + url, + data=data, + headers=request_headers, + timeout=timeout, + **kwargs + ) + timeout = guard.remaining_timeout # If the response indicated that the credentials needed to be # refreshed, then refresh the credentials and re-attempt the @@ -233,17 +280,33 @@ def request(self, method, url, data=None, headers=None, **kwargs): self._max_refresh_attempts, ) - auth_request_with_timeout = functools.partial( - self._auth_request, timeout=self._refresh_timeout + if self._refresh_timeout is not None: + timeout = ( + self._refresh_timeout + if timeout is None + else min(timeout, self._refresh_timeout) + ) + + # Do not apply the timeout unconditionally in order to not override the + # _auth_request's default timeout. + auth_request = ( + self._auth_request + if timeout is None + else functools.partial(self._auth_request, timeout=timeout) ) - self.credentials.refresh(auth_request_with_timeout) - # Recurse. Pass in the original headers, not our modified set. + with TimeoutGuard(timeout) as guard: + self.credentials.refresh(auth_request) + timeout = guard.remaining_timeout + + # Recurse. Pass in the original headers, not our modified set, but + # do pass the adjusted timeout (i.e. the remaining time). return self.request( method, url, data=data, headers=headers, + timeout=timeout, _credential_refresh_attempt=_credential_refresh_attempt + 1, **kwargs ) diff --git a/noxfile.py b/noxfile.py index aaf1bc57d..e170ee51d 100644 --- a/noxfile.py +++ b/noxfile.py @@ -16,6 +16,7 @@ TEST_DEPENDENCIES = [ "flask", + "freezegun", "mock", "oauth2client", "pytest", diff --git a/tests/transport/test_requests.py b/tests/transport/test_requests.py index 0e165ac54..252e4a67e 100644 --- a/tests/transport/test_requests.py +++ b/tests/transport/test_requests.py @@ -12,7 +12,12 @@ # See the License for the specific language governing permissions and # limitations under the License. +import datetime +import functools + +import freezegun import mock +import pytest import requests import requests.adapters from six.moves import http_client @@ -22,6 +27,12 @@ from tests.transport import compliance +@pytest.fixture +def frozen_time(): + with freezegun.freeze_time("1970-01-01 00:00:00", tick=False) as frozen: + yield frozen + + class TestRequestResponse(compliance.RequestResponseTests): def make_request(self): return google.auth.transport.requests.Request() @@ -34,6 +45,41 @@ def test_timeout(self): assert http.request.call_args[1]["timeout"] == 5 +class TestTimeoutGuard(object): + def make_guard(self, *args, **kwargs): + return google.auth.transport.requests.TimeoutGuard(*args, **kwargs) + + def test_tracks_elapsed_time(self, frozen_time): + with self.make_guard(timeout=10) as guard: + frozen_time.tick(delta=3.8) + assert guard.remaining_timeout == 6.2 + + def test_noop_if_no_timeout(self, frozen_time): + with self.make_guard(timeout=None) as guard: + frozen_time.tick(delta=datetime.timedelta(days=3650)) + # NOTE: no timeout error raised, despite years have passed + assert guard.remaining_timeout is None + + def test_error_on_timeout(self, frozen_time): + with pytest.raises(requests.exceptions.Timeout): + with self.make_guard(timeout=10) as guard: + frozen_time.tick(delta=10.001) + assert guard.remaining_timeout == pytest.approx(-0.001) + + def test_custom_timeout_error_type(self, frozen_time): + class FooError(Exception): + pass + + with pytest.raises(FooError): + with self.make_guard(timeout=1, timeout_error_type=FooError): + frozen_time.tick(2) + + def test_lets_errors_bubble_up(self, frozen_time): + with pytest.raises(IndexError): + with self.make_guard(timeout=1): + [1, 2, 3][3] + + class CredentialsStub(google.auth.credentials.Credentials): def __init__(self, token="token"): super(CredentialsStub, self).__init__() @@ -49,6 +95,18 @@ def refresh(self, request): self.token += "1" +class TimeTickCredentialsStub(CredentialsStub): + """Credentials that spend some (mocked) time when refreshing a token.""" + + def __init__(self, time_tick, token="token"): + self._time_tick = time_tick + super(TimeTickCredentialsStub, self).__init__(token=token) + + def refresh(self, request): + self._time_tick() + super(TimeTickCredentialsStub, self).refresh(requests) + + class AdapterStub(requests.adapters.BaseAdapter): def __init__(self, responses, headers=None): super(AdapterStub, self).__init__() @@ -69,6 +127,18 @@ def close(self): # pragma: NO COVER return +class TimeTickAdapterStub(AdapterStub): + """Adapter that spends some (mocked) time when making a request.""" + + def __init__(self, time_tick, responses, headers=None): + self._time_tick = time_tick + super(TimeTickAdapterStub, self).__init__(responses, headers=headers) + + def send(self, request, **kwargs): + self._time_tick() + return super(TimeTickAdapterStub, self).send(request, **kwargs) + + def make_response(status=http_client.OK, data=None): response = requests.Response() response.status_code = status @@ -121,7 +191,9 @@ def test_request_refresh(self): [make_response(status=http_client.UNAUTHORIZED), final_response] ) - authed_session = google.auth.transport.requests.AuthorizedSession(credentials) + authed_session = google.auth.transport.requests.AuthorizedSession( + credentials, refresh_timeout=60 + ) authed_session.mount(self.TEST_URL, adapter) result = authed_session.request("GET", self.TEST_URL) @@ -136,3 +208,44 @@ def test_request_refresh(self): assert adapter.requests[1].url == self.TEST_URL assert adapter.requests[1].headers["authorization"] == "token1" + + def test_request_timout(self, frozen_time): + tick_one_second = functools.partial(frozen_time.tick, delta=1.0) + + credentials = mock.Mock( + wraps=TimeTickCredentialsStub(time_tick=tick_one_second) + ) + adapter = TimeTickAdapterStub( + time_tick=tick_one_second, + responses=[ + make_response(status=http_client.UNAUTHORIZED), + make_response(status=http_client.OK), + ], + ) + + authed_session = google.auth.transport.requests.AuthorizedSession(credentials) + authed_session.mount(self.TEST_URL, adapter) + + # Because at least two requests have to be made, and each takes one + # second, the total timeout specified will be exceeded. + with pytest.raises(requests.exceptions.Timeout): + authed_session.request("GET", self.TEST_URL, timeout=1.9) + + def test_request_timeout_w_refresh_timeout(self, frozen_time): + credentials = mock.Mock(wraps=CredentialsStub()) + adapter = TimeTickAdapterStub( + time_tick=functools.partial(frozen_time.tick, delta=1.0), # one second + responses=[ + make_response(status=http_client.UNAUTHORIZED), + make_response(status=http_client.OK), + ], + ) + + authed_session = google.auth.transport.requests.AuthorizedSession( + credentials, refresh_timeout=0.9 + ) + authed_session.mount(self.TEST_URL, adapter) + + # The timeout is long, but the short refresh timeout will prevail. + with pytest.raises(requests.exceptions.Timeout): + authed_session.request("GET", self.TEST_URL, timeout=60) From d97b2716f52f43380269b251e9f0bd72a5105722 Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Mon, 9 Dec 2019 14:01:00 -0800 Subject: [PATCH 4/4] chore: release 1.8.0 (#399) --- CHANGELOG.md | 9 +++++++++ setup.py | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c7a94045..481296b71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ [1]: https://pypi.org/project/google-auth/#history +## [1.8.0](https://www.github.com/googleapis/google-auth-library-python/compare/v1.7.2...v1.8.0) (2019-12-09) + + +### Features + +* add `to_json` method to google.oauth2.credentials.Credentials ([#367](https://www.github.com/googleapis/google-auth-library-python/issues/367)) ([bfb1f8c](https://www.github.com/googleapis/google-auth-library-python/commit/bfb1f8cc8a706ce5ca2a14886c920ca2220ec349)) +* add timeout to AuthorizedSession.request() ([#397](https://www.github.com/googleapis/google-auth-library-python/issues/397)) ([381dd40](https://www.github.com/googleapis/google-auth-library-python/commit/381dd400911d29926ffbf04e0f2ba53ef7bb997e)) +* send quota project id in x-goog-user-project header for OAuth2 credentials ([#400](https://www.github.com/googleapis/google-auth-library-python/issues/400)) ([ab3dc1e](https://www.github.com/googleapis/google-auth-library-python/commit/ab3dc1e26f5240ea3456de364c7c5cb8f40f9583)) + ### [1.7.2](https://www.github.com/googleapis/google-auth-library-python/compare/v1.7.1...v1.7.2) (2019-12-02) diff --git a/setup.py b/setup.py index 0fd04c6bd..bad946fff 100644 --- a/setup.py +++ b/setup.py @@ -30,7 +30,7 @@ with io.open("README.rst", "r") as fh: long_description = fh.read() -version = "1.7.2" +version = "1.8.0" setup( name="google-auth",