From 175acb83b9f29cdc34919b8d470541e665f80e28 Mon Sep 17 00:00:00 2001 From: David Buxton Date: Sun, 25 Oct 2020 17:47:16 +0000 Subject: [PATCH] feat: Add custom scopes for access tokens from the metadata service This works for App Engine, Cloud Run and Flex. On Compute Engine you can request custom scopes, but they are ignored. --- google/auth/_default.py | 10 +++-- google/auth/_default_async.py | 10 +++-- google/auth/compute_engine/_metadata.py | 17 +++++--- google/auth/compute_engine/credentials.py | 44 ++++++++++++------- system_tests/noxfile.py | 2 +- tests/compute_engine/test_credentials.py | 51 ++++++++++++++++++++++- 6 files changed, 104 insertions(+), 30 deletions(-) diff --git a/google/auth/_default.py b/google/auth/_default.py index de81c5b2c..43778931a 100644 --- a/google/auth/_default.py +++ b/google/auth/_default.py @@ -274,10 +274,11 @@ def default(scopes=None, request=None, quota_project_id=None): gcloud config set project 3. If the application is running in the `App Engine standard environment`_ - then the credentials and project ID from the `App Identity Service`_ - are used. - 4. If the application is running in `Compute Engine`_ or the - `App Engine flexible environment`_ then the credentials and project ID + (first generation) then the credentials and project ID from the + `App Identity Service`_ are used. + 4. If the application is running in `Compute Engine`_ or `Cloud Run`_ or + the `App Engine flexible environment`_ or the `App Engine standard + environment`_ (second generation) then the credentials and project ID are obtained from the `Metadata Service`_. 5. If no credentials are found, :class:`~google.auth.exceptions.DefaultCredentialsError` will be raised. @@ -293,6 +294,7 @@ def default(scopes=None, request=None, quota_project_id=None): /appengine/flexible .. _Metadata Service: https://cloud.google.com/compute/docs\ /storing-retrieving-metadata + .. _Cloud Run: https://cloud.google.com/run Example:: diff --git a/google/auth/_default_async.py b/google/auth/_default_async.py index 3347fbfdc..1a725afba 100644 --- a/google/auth/_default_async.py +++ b/google/auth/_default_async.py @@ -187,10 +187,11 @@ def default_async(scopes=None, request=None, quota_project_id=None): gcloud config set project 3. If the application is running in the `App Engine standard environment`_ - then the credentials and project ID from the `App Identity Service`_ - are used. - 4. If the application is running in `Compute Engine`_ or the - `App Engine flexible environment`_ then the credentials and project ID + (first generation) then the credentials and project ID from the + `App Identity Service`_ are used. + 4. If the application is running in `Compute Engine`_ or `Cloud Run`_ or + the `App Engine flexible environment`_ or the `App Engine standard + environment`_ (second generation) then the credentials and project ID are obtained from the `Metadata Service`_. 5. If no credentials are found, :class:`~google.auth.exceptions.DefaultCredentialsError` will be raised. @@ -206,6 +207,7 @@ def default_async(scopes=None, request=None, quota_project_id=None): /appengine/flexible .. _Metadata Service: https://cloud.google.com/compute/docs\ /storing-retrieving-metadata + .. _Cloud Run: https://cloud.google.com/run Example:: diff --git a/google/auth/compute_engine/_metadata.py b/google/auth/compute_engine/_metadata.py index 94e4ffbf0..5687a42f9 100644 --- a/google/auth/compute_engine/_metadata.py +++ b/google/auth/compute_engine/_metadata.py @@ -234,7 +234,7 @@ def get_service_account_info(request, service_account="default"): return get(request, path, params={"recursive": "true"}) -def get_service_account_token(request, service_account="default"): +def get_service_account_token(request, service_account="default", scopes=None): """Get the OAuth 2.0 access token for a service account. Args: @@ -243,7 +243,8 @@ def get_service_account_token(request, service_account="default"): service_account (str): The string 'default' or a service account email address. The determines which service account for which to acquire an access token. - + scopes (Optional[Union[str, List[str]]]): Optional string or list of + strings with auth scopes. Returns: Union[str, datetime]: The access token and its expiration. @@ -251,9 +252,15 @@ def get_service_account_token(request, service_account="default"): google.auth.exceptions.TransportError: if an error occurred while retrieving metadata. """ - token_json = get( - request, "instance/service-accounts/{0}/token".format(service_account) - ) + if scopes: + if not isinstance(scopes, str): + scopes = ",".join(scopes) + params = {"scopes": scopes} + else: + params = None + + path = "instance/service-accounts/{0}/token".format(service_account) + token_json = get(request, path, params=params) token_expiry = _helpers.utcnow() + datetime.timedelta( seconds=token_json["expires_in"] ) diff --git a/google/auth/compute_engine/credentials.py b/google/auth/compute_engine/credentials.py index 8a41ffcc0..4ac6c8c2c 100644 --- a/google/auth/compute_engine/credentials.py +++ b/google/auth/compute_engine/credentials.py @@ -32,29 +32,28 @@ from google.oauth2 import _client -class Credentials(credentials.ReadOnlyScoped, credentials.CredentialsWithQuotaProject): +class Credentials(credentials.Scoped, credentials.CredentialsWithQuotaProject): """Compute Engine Credentials. These credentials use the Google Compute Engine metadata server to obtain - OAuth 2.0 access tokens associated with the instance's service account. + OAuth 2.0 access tokens associated with the instance's service account, + and are also used for Cloud Run, Flex and App Engine (except for the Python + 2.7 runtime). For more information about Compute Engine authentication, including how to configure scopes, see the `Compute Engine authentication documentation`_. - .. note:: Compute Engine instances can be created with scopes and therefore - these credentials are considered to be 'scoped'. However, you can - not use :meth:`~google.auth.credentials.ScopedCredentials.with_scopes` - because it is not possible to change the scopes that the instance - has. Also note that - :meth:`~google.auth.credentials.ScopedCredentials.has_scopes` will not - work until the credentials have been refreshed. + .. note:: On Compute Engine the metadata server ignores requested scopes. + On Cloud Run, Flex and App Engine the server honours requested scopes. .. _Compute Engine authentication documentation: https://cloud.google.com/compute/docs/authentication#using """ - def __init__(self, service_account_email="default", quota_project_id=None): + def __init__( + self, service_account_email="default", quota_project_id=None, scopes=None + ): """ Args: service_account_email (str): The service account email to use, or @@ -66,6 +65,7 @@ def __init__(self, service_account_email="default", quota_project_id=None): super(Credentials, self).__init__() self._service_account_email = service_account_email self._quota_project_id = quota_project_id + self._scopes = scopes def _retrieve_info(self, request): """Retrieve information about the service account. @@ -81,7 +81,10 @@ def _retrieve_info(self, request): ) self._service_account_email = info["email"] - self._scopes = info["scopes"] + + # Don't override scopes requested by the user. + if self._scopes is None: + self._scopes = info["scopes"] def refresh(self, request): """Refresh the access token and scopes. @@ -98,7 +101,9 @@ def refresh(self, request): try: self._retrieve_info(request) self.token, self.expiry = _metadata.get_service_account_token( - request, service_account=self._service_account_email + request, + service_account=self._service_account_email, + scopes=self._scopes, ) except exceptions.TransportError as caught_exc: new_exc = exceptions.RefreshError(caught_exc) @@ -115,14 +120,25 @@ def service_account_email(self): @property def requires_scopes(self): - """False: Compute Engine credentials can not be scoped.""" - return False + return not self._scopes @_helpers.copy_docstring(credentials.CredentialsWithQuotaProject) def with_quota_project(self, quota_project_id): return self.__class__( service_account_email=self._service_account_email, quota_project_id=quota_project_id, + scopes=self._scopes, + ) + + @_helpers.copy_docstring(credentials.Scoped) + def with_scopes(self, scopes): + # Compute Engine credentials can not be scoped (the metadata service + # ignores the scopes parameter). App Engine, Cloud Run and Flex support + # requesting scopes. + return self.__class__( + scopes=scopes, + service_account_email=self._service_account_email, + quota_project_id=self._quota_project_id, ) diff --git a/system_tests/noxfile.py b/system_tests/noxfile.py index a039228d9..dcfe8ee81 100644 --- a/system_tests/noxfile.py +++ b/system_tests/noxfile.py @@ -315,7 +315,7 @@ def default_explicit_service_account_async(session): session.env[EXPECT_PROJECT_ENV] = "1" session.install(*(TEST_DEPENDENCIES_SYNC + TEST_DEPENDENCIES_ASYNC)) session.install(LIBRARY_DIR) - session.run("pytest", "system_tests_async/test_default.py", + session.run("pytest", "system_tests_async/test_default.py", "system_tests_async/test_id_token.py") diff --git a/tests/compute_engine/test_credentials.py b/tests/compute_engine/test_credentials.py index 4ee653676..ebe9aa5ba 100644 --- a/tests/compute_engine/test_credentials.py +++ b/tests/compute_engine/test_credentials.py @@ -55,8 +55,8 @@ def test_default_state(self): assert not self.credentials.valid # Expiration hasn't been set yet assert not self.credentials.expired - # Scopes aren't needed - assert not self.credentials.requires_scopes + # Scopes are needed + assert self.credentials.requires_scopes # Service account email hasn't been populated assert self.credentials.service_account_email == "default" # No quota project @@ -96,6 +96,45 @@ def test_refresh_success(self, get, utcnow): # expired) assert self.credentials.valid + @mock.patch( + "google.auth._helpers.utcnow", + return_value=datetime.datetime.min + _helpers.CLOCK_SKEW, + ) + @mock.patch("google.auth.compute_engine._metadata.get", autospec=True) + def test_refresh_success_with_scopes(self, get, utcnow): + get.side_effect = [ + { + # First request is for sevice account info. + "email": "service-account@example.com", + "scopes": ["one", "two"], + }, + { + # Second request is for the token. + "access_token": "token", + "expires_in": 500, + }, + ] + + # Refresh credentials + scopes = ["three", "four"] + self.credentials = self.credentials.with_scopes(scopes) + self.credentials.refresh(None) + + # Check that the credentials have the token and proper expiration + assert self.credentials.token == "token" + assert self.credentials.expiry == (utcnow() + datetime.timedelta(seconds=500)) + + # Check the credential info + assert self.credentials.service_account_email == "service-account@example.com" + assert self.credentials._scopes == scopes + + # Check that the credentials are valid (have a token and are not + # expired) + assert self.credentials.valid + + kwargs = get.call_args[1] + assert kwargs == {"params": {"scopes": "three,four"}} + @mock.patch("google.auth.compute_engine._metadata.get", autospec=True) def test_refresh_error(self, get): get.side_effect = exceptions.TransportError("http error") @@ -138,6 +177,14 @@ def test_with_quota_project(self): assert quota_project_creds._quota_project_id == "project-foo" + def test_with_scopes(self): + assert self.credentials._scopes is None + + scopes = ["one", "two"] + self.credentials = self.credentials.with_scopes(scopes) + + assert self.credentials._scopes == scopes + class TestIDTokenCredentials(object): credentials = None