From a6dc2c336a5e76a478691e3bedd0418aae08b911 Mon Sep 17 00:00:00 2001 From: Carl Lundin <108372512+clundin25@users.noreply.github.com> Date: Mon, 18 Dec 2023 14:12:00 -0800 Subject: [PATCH] feat: Add optional non blocking refresh for sync auth code (#1368) feat: Add optional non blocking refresh for sync auth code --- google/auth/_refresh_worker.py | 98 +++++++++++++ google/auth/credentials.py | 76 +++++++++- google/auth/impersonated_credentials.py | 5 +- google/oauth2/credentials.py | 4 + system_tests/secrets.tar.enc | Bin 10324 -> 10324 bytes tests/oauth2/test_credentials.py | 10 +- tests/test__refresh_worker.py | 147 +++++++++++++++++++ tests/test_credentials.py | 118 +++++++++++++++ tests/test_downscoped.py | 18 +++ tests/test_external_account.py | 10 ++ tests/test_impersonated_credentials.py | 2 +- tests_async/oauth2/test_credentials_async.py | 5 +- 12 files changed, 485 insertions(+), 8 deletions(-) create mode 100644 google/auth/_refresh_worker.py create mode 100644 tests/test__refresh_worker.py diff --git a/google/auth/_refresh_worker.py b/google/auth/_refresh_worker.py new file mode 100644 index 000000000..cb115b939 --- /dev/null +++ b/google/auth/_refresh_worker.py @@ -0,0 +1,98 @@ +# Copyright 2023 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import copy +import logging +import threading + +import google.auth.exceptions as e + +_LOGGER = logging.getLogger(__name__) + + +class RefreshThreadManager: + """ + Organizes exactly one background job that refresh a token. + """ + + def __init__(self): + """Initializes the manager.""" + + self._worker = None + self._lock = threading.Lock() # protects access to worker threads. + + def start_refresh(self, cred, request): + """Starts a refresh thread for the given credentials. + The credentials are refreshed using the request parameter. + request and cred MUST not be None + + Returns True if a background refresh was kicked off. False otherwise. + + Args: + cred: A credentials object. + request: A request object. + Returns: + bool + """ + if cred is None or request is None: + raise e.InvalidValue( + "Unable to start refresh. cred and request must be valid and instantiated objects." + ) + + with self._lock: + if self._worker is not None and self._worker._error_info is not None: + return False + + if self._worker is None or not self._worker.is_alive(): # pragma: NO COVER + self._worker = RefreshThread(cred=cred, request=copy.deepcopy(request)) + self._worker.start() + return True + + def clear_error(self): + """ + Removes any errors that were stored from previous background refreshes. + """ + with self._lock: + if self._worker: + self._worker._error_info = None + + +class RefreshThread(threading.Thread): + """ + Thread that refreshes credentials. + """ + + def __init__(self, cred, request, **kwargs): + """Initializes the thread. + + Args: + cred: A Credential object to refresh. + request: A Request object used to perform a credential refresh. + **kwargs: Additional keyword arguments. + """ + + super().__init__(**kwargs) + self._cred = cred + self._request = request + self._error_info = None + + def run(self): + """ + Perform the credential refresh. + """ + try: + self._cred.refresh(self._request) + except Exception as err: # pragma: NO COVER + _LOGGER.error(f"Background refresh failed due to: {err}") + self._error_info = err diff --git a/google/auth/credentials.py b/google/auth/credentials.py index 6e62a4b4e..a4fa1829c 100644 --- a/google/auth/credentials.py +++ b/google/auth/credentials.py @@ -16,11 +16,13 @@ """Interfaces for credentials.""" import abc +from enum import Enum import os from google.auth import _helpers, environment_vars from google.auth import exceptions from google.auth import metrics +from google.auth._refresh_worker import RefreshThreadManager class Credentials(metaclass=abc.ABCMeta): @@ -59,6 +61,9 @@ def __init__(self): """Optional[str]: The universe domain value, default is googleapis.com """ + self._use_non_blocking_refresh = False + self._refresh_worker = RefreshThreadManager() + @property def expired(self): """Checks if the credentials are expired. @@ -66,10 +71,12 @@ def expired(self): Note that credentials can be invalid but not expired because Credentials with :attr:`expiry` set to None is considered to never expire. + + .. deprecated:: v2.24.0 + Prefer checking :attr:`token_state` instead. """ if not self.expiry: return False - # Remove some threshold from expiry to err on the side of reporting # expiration early so that we avoid the 401-refresh-retry loop. skewed_expiry = self.expiry - _helpers.REFRESH_THRESHOLD @@ -81,9 +88,34 @@ def valid(self): This is True if the credentials have a :attr:`token` and the token is not :attr:`expired`. + + .. deprecated:: v2.24.0 + Prefer checking :attr:`token_state` instead. """ return self.token is not None and not self.expired + @property + def token_state(self): + """ + See `:obj:`TokenState` + """ + if self.token is None: + return TokenState.INVALID + + # Credentials that can't expire are always treated as fresh. + if self.expiry is None: + return TokenState.FRESH + + expired = _helpers.utcnow() >= self.expiry + if expired: + return TokenState.INVALID + + is_stale = _helpers.utcnow() >= (self.expiry - _helpers.REFRESH_THRESHOLD) + if is_stale: + return TokenState.STALE + + return TokenState.FRESH + @property def quota_project_id(self): """Project to use for quota and billing purposes.""" @@ -154,6 +186,25 @@ def apply(self, headers, token=None): if self.quota_project_id: headers["x-goog-user-project"] = self.quota_project_id + def _blocking_refresh(self, request): + if not self.valid: + self.refresh(request) + + def _non_blocking_refresh(self, request): + use_blocking_refresh_fallback = False + + if self.token_state == TokenState.STALE: + use_blocking_refresh_fallback = not self._refresh_worker.start_refresh( + self, request + ) + + if self.token_state == TokenState.INVALID or use_blocking_refresh_fallback: + self.refresh(request) + # If the blocking refresh succeeds then we can clear the error info + # on the background refresh worker, and perform refreshes in a + # background thread. + self._refresh_worker.clear_error() + def before_request(self, request, method, url, headers): """Performs credential-specific before request logic. @@ -171,11 +222,17 @@ def before_request(self, request, method, url, headers): # pylint: disable=unused-argument # (Subclasses may use these arguments to ascertain information about # the http request.) - if not self.valid: - self.refresh(request) + if self._use_non_blocking_refresh: + self._non_blocking_refresh(request) + else: + self._blocking_refresh(request) + metrics.add_metric_header(headers, self._metric_header_for_usage()) self.apply(headers) + def with_non_blocking_refresh(self): + self._use_non_blocking_refresh = True + class CredentialsWithQuotaProject(Credentials): """Abstract base for credentials supporting ``with_quota_project`` factory""" @@ -439,3 +496,16 @@ def signer(self): # pylint: disable=missing-raises-doc # (pylint doesn't recognize that this is abstract) raise NotImplementedError("Signer must be implemented.") + + +class TokenState(Enum): + """ + Tracks the state of a token. + FRESH: The token is valid. It is not expired or close to expired, or the token has no expiry. + STALE: The token is close to expired, and should be refreshed. The token can be used normally. + INVALID: The token is expired or invalid. The token cannot be used for a normal operation. + """ + + FRESH = 1 + STALE = 2 + INVALID = 3 diff --git a/google/auth/impersonated_credentials.py b/google/auth/impersonated_credentials.py index c272a3ca2..d32e6eb69 100644 --- a/google/auth/impersonated_credentials.py +++ b/google/auth/impersonated_credentials.py @@ -259,7 +259,10 @@ def _update_token(self, request): """ # Refresh our source credentials if it is not valid. - if not self._source_credentials.valid: + if ( + self._source_credentials.token_state == credentials.TokenState.STALE + or self._source_credentials.token_state == credentials.TokenState.INVALID + ): self._source_credentials.refresh(request) body = { diff --git a/google/oauth2/credentials.py b/google/oauth2/credentials.py index 7d327c110..4ad52db90 100644 --- a/google/oauth2/credentials.py +++ b/google/oauth2/credentials.py @@ -161,6 +161,8 @@ def __getstate__(self): # because they need to be importable. # Instead, the refresh_handler setter should be used to repopulate this. del state_dict["_refresh_handler"] + # Remove worker as it contains multiproccessing queue objects. + del state_dict["_refresh_worker"] return state_dict def __setstate__(self, d): @@ -183,6 +185,8 @@ def __setstate__(self, d): self._universe_domain = d.get("_universe_domain") or _DEFAULT_UNIVERSE_DOMAIN # The refresh_handler setter should be used to repopulate this. self._refresh_handler = None + self._refresh_worker = None + self._use_non_blocking_refresh = d.get("_use_non_blocking_refresh") @property def refresh_token(self): diff --git a/system_tests/secrets.tar.enc b/system_tests/secrets.tar.enc index b6fddac9532eecb2731f31f040b062e52aa028e1..493b2222e457e801bb11b38d86d0392be9be2ce0 100644 GIT binary patch literal 10324 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 literal 10324 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 diff --git a/tests/oauth2/test_credentials.py b/tests/oauth2/test_credentials.py index 7d4917739..78711299e 100644 --- a/tests/oauth2/test_credentials.py +++ b/tests/oauth2/test_credentials.py @@ -24,6 +24,7 @@ from google.auth import _helpers from google.auth import exceptions from google.auth import transport +from google.auth.credentials import TokenState from google.oauth2 import credentials @@ -61,6 +62,7 @@ def test_default_state(self): assert not credentials.expired # Scopes aren't required for these credentials assert not credentials.requires_scopes + assert credentials.token_state == TokenState.INVALID # Test properties assert credentials.refresh_token == self.REFRESH_TOKEN assert credentials.token_uri == self.TOKEN_URI @@ -911,7 +913,11 @@ def test_pickle_and_unpickle(self): assert list(creds.__dict__).sort() == list(unpickled.__dict__).sort() for attr in list(creds.__dict__): - assert getattr(creds, attr) == getattr(unpickled, attr) + # Worker should always be None + if attr == "_refresh_worker": + assert getattr(unpickled, attr) is None + else: + assert getattr(creds, attr) == getattr(unpickled, attr) def test_pickle_and_unpickle_universe_domain(self): # old version of auth lib doesn't have _universe_domain, so the pickled @@ -945,7 +951,7 @@ def test_pickle_and_unpickle_with_refresh_handler(self): for attr in list(creds.__dict__): # For the _refresh_handler property, the unpickled creds should be # set to None. - if attr == "_refresh_handler": + if attr == "_refresh_handler" or attr == "_refresh_worker": assert getattr(unpickled, attr) is None else: assert getattr(creds, attr) == getattr(unpickled, attr) diff --git a/tests/test__refresh_worker.py b/tests/test__refresh_worker.py new file mode 100644 index 000000000..1fbbf1625 --- /dev/null +++ b/tests/test__refresh_worker.py @@ -0,0 +1,147 @@ +# Copyright 2023 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import random +import threading +import time + +import mock +import pytest # type: ignore + +from google.auth import _refresh_worker, credentials, exceptions + +MAIN_THREAD_SLEEP_MS = 100 / 1000 + + +class MockCredentialsImpl(credentials.Credentials): + def __init__(self, sleep_seconds=None): + self.refresh_count = 0 + self.token = None + self.sleep_seconds = sleep_seconds if sleep_seconds else None + + def refresh(self, request): + if self.sleep_seconds: + time.sleep(self.sleep_seconds) + self.token = request + self.refresh_count += 1 + + +@pytest.fixture +def test_thread_count(): + return 25 + + +def _cred_spinlock(cred): + while cred.token is None: # pragma: NO COVER + time.sleep(MAIN_THREAD_SLEEP_MS) + + +def test_invalid_start_refresh(): + w = _refresh_worker.RefreshThreadManager() + with pytest.raises(exceptions.InvalidValue): + w.start_refresh(None, None) + + +def test_start_refresh(): + w = _refresh_worker.RefreshThreadManager() + cred = MockCredentialsImpl() + request = mock.MagicMock() + assert w.start_refresh(cred, request) + + assert w._worker is not None + + _cred_spinlock(cred) + + assert cred.token == request + assert cred.refresh_count == 1 + + +def test_nonblocking_start_refresh(): + w = _refresh_worker.RefreshThreadManager() + cred = MockCredentialsImpl(sleep_seconds=1) + request = mock.MagicMock() + assert w.start_refresh(cred, request) + + assert w._worker is not None + assert not cred.token + assert cred.refresh_count == 0 + + +def test_multiple_refreshes_multiple_workers(test_thread_count): + w = _refresh_worker.RefreshThreadManager() + cred = MockCredentialsImpl() + request = mock.MagicMock() + + def _thread_refresh(): + time.sleep(random.randrange(0, 5)) + assert w.start_refresh(cred, request) + + threads = [ + threading.Thread(target=_thread_refresh) for _ in range(test_thread_count) + ] + for t in threads: + t.start() + + _cred_spinlock(cred) + + assert cred.token == request + # There is a chance only one thread has enough time to perform a refresh. + # Generally multiple threads will have time to perform a refresh + assert cred.refresh_count > 0 + + +def test_refresh_error(): + w = _refresh_worker.RefreshThreadManager() + cred = mock.MagicMock() + request = mock.MagicMock() + + cred.refresh.side_effect = exceptions.RefreshError("Failed to refresh") + + assert w.start_refresh(cred, request) + + while w._worker._error_info is None: # pragma: NO COVER + time.sleep(MAIN_THREAD_SLEEP_MS) + + assert w._worker is not None + assert isinstance(w._worker._error_info, exceptions.RefreshError) + + +def test_refresh_error_call_refresh_again(): + w = _refresh_worker.RefreshThreadManager() + cred = mock.MagicMock() + request = mock.MagicMock() + + cred.refresh.side_effect = exceptions.RefreshError("Failed to refresh") + + assert w.start_refresh(cred, request) + + while w._worker._error_info is None: # pragma: NO COVER + time.sleep(MAIN_THREAD_SLEEP_MS) + + assert not w.start_refresh(cred, request) + + +def test_refresh_dead_worker(): + cred = MockCredentialsImpl() + request = mock.MagicMock() + + w = _refresh_worker.RefreshThreadManager() + w._worker = None + + w.start_refresh(cred, request) + + _cred_spinlock(cred) + + assert cred.token == request + assert cred.refresh_count == 1 diff --git a/tests/test_credentials.py b/tests/test_credentials.py index d64f3abb5..8e6bbc963 100644 --- a/tests/test_credentials.py +++ b/tests/test_credentials.py @@ -14,6 +14,7 @@ import datetime +import mock import pytest # type: ignore from google.auth import _helpers @@ -23,6 +24,11 @@ class CredentialsImpl(credentials.Credentials): def refresh(self, request): self.token = request + self.expiry = ( + datetime.datetime.utcnow() + + _helpers.REFRESH_THRESHOLD + + datetime.timedelta(seconds=5) + ) def with_quota_project(self, quota_project_id): raise NotImplementedError() @@ -43,6 +49,13 @@ def test_credentials_constructor(): assert not credentials.expired assert not credentials.valid assert credentials.universe_domain == "googleapis.com" + assert not credentials._use_non_blocking_refresh + + +def test_with_non_blocking_refresh(): + c = CredentialsImpl() + c.with_non_blocking_refresh() + assert c._use_non_blocking_refresh def test_expired_and_valid(): @@ -220,3 +233,108 @@ def test_create_scoped_if_required_not_scopes(): ) assert scoped_credentials is unscoped_credentials + + +def test_nonblocking_refresh_fresh_credentials(): + c = CredentialsImpl() + + c._refresh_worker = mock.MagicMock() + + request = "token" + + c.refresh(request) + assert c.token_state == credentials.TokenState.FRESH + + c.with_non_blocking_refresh() + c.before_request(request, "http://example.com", "GET", {}) + + +def test_nonblocking_refresh_invalid_credentials(): + c = CredentialsImpl() + c.with_non_blocking_refresh() + + request = "token" + headers = {} + + assert c.token_state == credentials.TokenState.INVALID + + c.before_request(request, "http://example.com", "GET", headers) + assert c.token_state == credentials.TokenState.FRESH + assert c.valid + assert c.token == "token" + assert headers["authorization"] == "Bearer token" + assert "x-identity-trust-boundary" not in headers + + +def test_nonblocking_refresh_stale_credentials(): + c = CredentialsImpl() + c.with_non_blocking_refresh() + + request = "token" + headers = {} + + # Invalid credentials MUST require a blocking refresh. + c.before_request(request, "http://example.com", "GET", headers) + assert c.token_state == credentials.TokenState.FRESH + assert not c._refresh_worker._worker + + c.expiry = ( + datetime.datetime.utcnow() + + _helpers.REFRESH_THRESHOLD + - datetime.timedelta(seconds=1) + ) + + # STALE credentials SHOULD spawn a non-blocking worker + assert c.token_state == credentials.TokenState.STALE + c.before_request(request, "http://example.com", "GET", headers) + assert c._refresh_worker._worker is not None + + assert c.token_state == credentials.TokenState.FRESH + assert c.valid + assert c.token == "token" + assert headers["authorization"] == "Bearer token" + assert "x-identity-trust-boundary" not in headers + + +def test_nonblocking_refresh_failed_credentials(): + c = CredentialsImpl() + c.with_non_blocking_refresh() + + request = "token" + headers = {} + + # Invalid credentials MUST require a blocking refresh. + c.before_request(request, "http://example.com", "GET", headers) + assert c.token_state == credentials.TokenState.FRESH + assert not c._refresh_worker._worker + + c.expiry = ( + datetime.datetime.utcnow() + + _helpers.REFRESH_THRESHOLD + - datetime.timedelta(seconds=1) + ) + + # STALE credentials SHOULD spawn a non-blocking worker + assert c.token_state == credentials.TokenState.STALE + c._refresh_worker._worker = mock.MagicMock() + c._refresh_worker._worker._error_info = "Some Error" + c.before_request(request, "http://example.com", "GET", headers) + assert c._refresh_worker._worker is not None + + assert c.token_state == credentials.TokenState.FRESH + assert c.valid + assert c.token == "token" + assert headers["authorization"] == "Bearer token" + assert "x-identity-trust-boundary" not in headers + + +def test_token_state_no_expiry(): + c = CredentialsImpl() + + request = "token" + c.refresh(request) + + c.expiry = None + assert c.token_state == credentials.TokenState.FRESH + + c.before_request(request, "http://example.com", "GET", {}) diff --git a/tests/test_downscoped.py b/tests/test_downscoped.py index b011380bd..8cc2a30d1 100644 --- a/tests/test_downscoped.py +++ b/tests/test_downscoped.py @@ -25,6 +25,7 @@ from google.auth import downscoped from google.auth import exceptions from google.auth import transport +from google.auth.credentials import TokenState EXPRESSION = ( @@ -676,6 +677,7 @@ def test_before_request_expired(self, utcnow): assert credentials.valid assert not credentials.expired + assert credentials.token_state == TokenState.FRESH credentials.before_request(request, "POST", "https://example.com/api", headers) @@ -687,8 +689,24 @@ def test_before_request_expired(self, utcnow): assert not credentials.valid assert credentials.expired + assert credentials.token_state == TokenState.STALE credentials.before_request(request, "POST", "https://example.com/api", headers) + assert credentials.token_state == TokenState.FRESH + + # New token should be retrieved. + assert headers == { + "authorization": "Bearer {}".format(SUCCESS_RESPONSE["access_token"]) + } + + utcnow.return_value = datetime.datetime.min + datetime.timedelta(seconds=6000) + + assert not credentials.valid + assert credentials.expired + assert credentials.token_state == TokenState.INVALID + + credentials.before_request(request, "POST", "https://example.com/api", headers) + assert credentials.token_state == TokenState.FRESH # New token should be retrieved. assert headers == { diff --git a/tests/test_external_account.py b/tests/test_external_account.py index 5225dcf34..7f33b1dfa 100644 --- a/tests/test_external_account.py +++ b/tests/test_external_account.py @@ -24,6 +24,7 @@ from google.auth import exceptions from google.auth import external_account from google.auth import transport +from google.auth.credentials import TokenState IMPERSONATE_ACCESS_TOKEN_REQUEST_METRICS_HEADER_VALUE = ( @@ -1494,6 +1495,7 @@ def test_before_request_expired(self, utcnow): assert credentials.valid assert not credentials.expired + assert credentials.token_state == TokenState.FRESH credentials.before_request(request, "POST", "https://example.com/api", headers) @@ -1508,8 +1510,10 @@ def test_before_request_expired(self, utcnow): assert not credentials.valid assert credentials.expired + assert credentials.token_state == TokenState.STALE credentials.before_request(request, "POST", "https://example.com/api", headers) + assert credentials.token_state == TokenState.FRESH # New token should be retrieved. assert headers == { @@ -1551,8 +1555,10 @@ def test_before_request_impersonation_expired(self, utcnow): assert credentials.valid assert not credentials.expired + assert credentials.token_state == TokenState.FRESH credentials.before_request(request, "POST", "https://example.com/api", headers) + assert credentials.token_state == TokenState.FRESH # Cached token should be used. assert headers == { @@ -1566,6 +1572,10 @@ def test_before_request_impersonation_expired(self, utcnow): assert not credentials.valid assert credentials.expired + assert credentials.token_state == TokenState.STALE + + credentials.before_request(request, "POST", "https://example.com/api", headers) + assert credentials.token_state == TokenState.FRESH credentials.before_request(request, "POST", "https://example.com/api", headers) diff --git a/tests/test_impersonated_credentials.py b/tests/test_impersonated_credentials.py index 9eb04b134..a2bf31bf8 100644 --- a/tests/test_impersonated_credentials.py +++ b/tests/test_impersonated_credentials.py @@ -242,7 +242,7 @@ def test_refresh_success_iam_endpoint_override( request_kwargs = request.call_args[1] assert request_kwargs["url"] == self.IAM_ENDPOINT_OVERRIDE - @pytest.mark.parametrize("time_skew", [100, -100]) + @pytest.mark.parametrize("time_skew", [150, -150]) def test_refresh_source_credentials(self, time_skew): credentials = self.make_credentials(lifetime=None) diff --git a/tests_async/oauth2/test_credentials_async.py b/tests_async/oauth2/test_credentials_async.py index f6c640ad6..fba0c3cf9 100644 --- a/tests_async/oauth2/test_credentials_async.py +++ b/tests_async/oauth2/test_credentials_async.py @@ -433,7 +433,10 @@ def test_pickle_and_unpickle(self): assert list(creds.__dict__).sort() == list(unpickled.__dict__).sort() for attr in list(creds.__dict__): - assert getattr(creds, attr) == getattr(unpickled, attr) + if attr == "_refresh_worker": + assert getattr(unpickled, attr) is None + else: + assert getattr(creds, attr) == getattr(unpickled, attr) def test_pickle_with_missing_attribute(self): creds = self.make_credentials()