Skip to content

Commit

Permalink
feat: Add custom scopes for access tokens from the metadata service (#…
Browse files Browse the repository at this point in the history
…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>
  • Loading branch information
3 people authored Oct 29, 2020
1 parent d0a47c1 commit 0323cf3
Show file tree
Hide file tree
Showing 6 changed files with 104 additions and 30 deletions.
10 changes: 6 additions & 4 deletions google/auth/_default.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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::
Expand Down
10 changes: 6 additions & 4 deletions google/auth/_default_async.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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::
Expand Down
17 changes: 12 additions & 5 deletions google/auth/compute_engine/_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -243,17 +243,24 @@ 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.
Raises:
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"]
)
Expand Down
44 changes: 30 additions & 14 deletions google/auth/compute_engine/credentials.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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.
Expand All @@ -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)
Expand All @@ -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,
)


Expand Down
2 changes: 1 addition & 1 deletion system_tests/noxfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")


Expand Down
51 changes: 49 additions & 2 deletions tests/compute_engine/test_credentials.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down

0 comments on commit 0323cf3

Please sign in to comment.