feat: Add custom scopes for access tokens from the metadata service (#633)

This works for App Engine, Cloud Run and Flex. On Compute Engine you
can request custom scopes, but they are ignored.

Co-authored-by: Tres Seaver <tseaver@palladion.com>
Co-authored-by: Bu Sun Kim <8822365+busunkim96@users.noreply.github.com>
diff --git a/google/auth/_default.py b/google/auth/_default.py
index de81c5b..4377893 100644
--- a/google/auth/_default.py
+++ b/google/auth/_default.py
@@ -274,10 +274,11 @@
             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 @@
             /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 3347fbf..1a725af 100644
--- a/google/auth/_default_async.py
+++ b/google/auth/_default_async.py
@@ -187,10 +187,11 @@
             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 @@
             /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 94e4ffb..5687a42 100644
--- a/google/auth/compute_engine/_metadata.py
+++ b/google/auth/compute_engine/_metadata.py
@@ -234,7 +234,7 @@
     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 @@
         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 @@
         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 8a41ffc..4ac6c8c 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 @@
         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 @@
         )
 
         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 @@
         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 @@
 
     @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 0f852ea..699a1b3 100644
--- a/system_tests/noxfile.py
+++ b/system_tests/noxfile.py
@@ -315,7 +315,7 @@
     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 4ee6536..ebe9aa5 100644
--- a/tests/compute_engine/test_credentials.py
+++ b/tests/compute_engine/test_credentials.py
@@ -55,8 +55,8 @@
         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 @@
         # 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 @@
 
         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