Skip to content

Commit 0323cf3

Browse files
davidwtbuxtontseaverbusunkim96
authored
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>
1 parent d0a47c1 commit 0323cf3

File tree

6 files changed

+104
-30
lines changed

6 files changed

+104
-30
lines changed

google/auth/_default.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -274,10 +274,11 @@ def default(scopes=None, request=None, quota_project_id=None):
274274
gcloud config set project
275275
276276
3. If the application is running in the `App Engine standard environment`_
277-
then the credentials and project ID from the `App Identity Service`_
278-
are used.
279-
4. If the application is running in `Compute Engine`_ or the
280-
`App Engine flexible environment`_ then the credentials and project ID
277+
(first generation) then the credentials and project ID from the
278+
`App Identity Service`_ are used.
279+
4. If the application is running in `Compute Engine`_ or `Cloud Run`_ or
280+
the `App Engine flexible environment`_ or the `App Engine standard
281+
environment`_ (second generation) then the credentials and project ID
281282
are obtained from the `Metadata Service`_.
282283
5. If no credentials are found,
283284
:class:`~google.auth.exceptions.DefaultCredentialsError` will be raised.
@@ -293,6 +294,7 @@ def default(scopes=None, request=None, quota_project_id=None):
293294
/appengine/flexible
294295
.. _Metadata Service: https://cloud.google.com/compute/docs\
295296
/storing-retrieving-metadata
297+
.. _Cloud Run: https://cloud.google.com/run
296298
297299
Example::
298300

google/auth/_default_async.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -187,10 +187,11 @@ def default_async(scopes=None, request=None, quota_project_id=None):
187187
gcloud config set project
188188
189189
3. If the application is running in the `App Engine standard environment`_
190-
then the credentials and project ID from the `App Identity Service`_
191-
are used.
192-
4. If the application is running in `Compute Engine`_ or the
193-
`App Engine flexible environment`_ then the credentials and project ID
190+
(first generation) then the credentials and project ID from the
191+
`App Identity Service`_ are used.
192+
4. If the application is running in `Compute Engine`_ or `Cloud Run`_ or
193+
the `App Engine flexible environment`_ or the `App Engine standard
194+
environment`_ (second generation) then the credentials and project ID
194195
are obtained from the `Metadata Service`_.
195196
5. If no credentials are found,
196197
:class:`~google.auth.exceptions.DefaultCredentialsError` will be raised.
@@ -206,6 +207,7 @@ def default_async(scopes=None, request=None, quota_project_id=None):
206207
/appengine/flexible
207208
.. _Metadata Service: https://cloud.google.com/compute/docs\
208209
/storing-retrieving-metadata
210+
.. _Cloud Run: https://cloud.google.com/run
209211
210212
Example::
211213

google/auth/compute_engine/_metadata.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,7 @@ def get_service_account_info(request, service_account="default"):
234234
return get(request, path, params={"recursive": "true"})
235235

236236

237-
def get_service_account_token(request, service_account="default"):
237+
def get_service_account_token(request, service_account="default", scopes=None):
238238
"""Get the OAuth 2.0 access token for a service account.
239239
240240
Args:
@@ -243,17 +243,24 @@ def get_service_account_token(request, service_account="default"):
243243
service_account (str): The string 'default' or a service account email
244244
address. The determines which service account for which to acquire
245245
an access token.
246-
246+
scopes (Optional[Union[str, List[str]]]): Optional string or list of
247+
strings with auth scopes.
247248
Returns:
248249
Union[str, datetime]: The access token and its expiration.
249250
250251
Raises:
251252
google.auth.exceptions.TransportError: if an error occurred while
252253
retrieving metadata.
253254
"""
254-
token_json = get(
255-
request, "instance/service-accounts/{0}/token".format(service_account)
256-
)
255+
if scopes:
256+
if not isinstance(scopes, str):
257+
scopes = ",".join(scopes)
258+
params = {"scopes": scopes}
259+
else:
260+
params = None
261+
262+
path = "instance/service-accounts/{0}/token".format(service_account)
263+
token_json = get(request, path, params=params)
257264
token_expiry = _helpers.utcnow() + datetime.timedelta(
258265
seconds=token_json["expires_in"]
259266
)

google/auth/compute_engine/credentials.py

Lines changed: 30 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -32,29 +32,28 @@
3232
from google.oauth2 import _client
3333

3434

35-
class Credentials(credentials.ReadOnlyScoped, credentials.CredentialsWithQuotaProject):
35+
class Credentials(credentials.Scoped, credentials.CredentialsWithQuotaProject):
3636
"""Compute Engine Credentials.
3737
3838
These credentials use the Google Compute Engine metadata server to obtain
39-
OAuth 2.0 access tokens associated with the instance's service account.
39+
OAuth 2.0 access tokens associated with the instance's service account,
40+
and are also used for Cloud Run, Flex and App Engine (except for the Python
41+
2.7 runtime).
4042
4143
For more information about Compute Engine authentication, including how
4244
to configure scopes, see the `Compute Engine authentication
4345
documentation`_.
4446
45-
.. note:: Compute Engine instances can be created with scopes and therefore
46-
these credentials are considered to be 'scoped'. However, you can
47-
not use :meth:`~google.auth.credentials.ScopedCredentials.with_scopes`
48-
because it is not possible to change the scopes that the instance
49-
has. Also note that
50-
:meth:`~google.auth.credentials.ScopedCredentials.has_scopes` will not
51-
work until the credentials have been refreshed.
47+
.. note:: On Compute Engine the metadata server ignores requested scopes.
48+
On Cloud Run, Flex and App Engine the server honours requested scopes.
5249
5350
.. _Compute Engine authentication documentation:
5451
https://cloud.google.com/compute/docs/authentication#using
5552
"""
5653

57-
def __init__(self, service_account_email="default", quota_project_id=None):
54+
def __init__(
55+
self, service_account_email="default", quota_project_id=None, scopes=None
56+
):
5857
"""
5958
Args:
6059
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):
6665
super(Credentials, self).__init__()
6766
self._service_account_email = service_account_email
6867
self._quota_project_id = quota_project_id
68+
self._scopes = scopes
6969

7070
def _retrieve_info(self, request):
7171
"""Retrieve information about the service account.
@@ -81,7 +81,10 @@ def _retrieve_info(self, request):
8181
)
8282

8383
self._service_account_email = info["email"]
84-
self._scopes = info["scopes"]
84+
85+
# Don't override scopes requested by the user.
86+
if self._scopes is None:
87+
self._scopes = info["scopes"]
8588

8689
def refresh(self, request):
8790
"""Refresh the access token and scopes.
@@ -98,7 +101,9 @@ def refresh(self, request):
98101
try:
99102
self._retrieve_info(request)
100103
self.token, self.expiry = _metadata.get_service_account_token(
101-
request, service_account=self._service_account_email
104+
request,
105+
service_account=self._service_account_email,
106+
scopes=self._scopes,
102107
)
103108
except exceptions.TransportError as caught_exc:
104109
new_exc = exceptions.RefreshError(caught_exc)
@@ -115,14 +120,25 @@ def service_account_email(self):
115120

116121
@property
117122
def requires_scopes(self):
118-
"""False: Compute Engine credentials can not be scoped."""
119-
return False
123+
return not self._scopes
120124

121125
@_helpers.copy_docstring(credentials.CredentialsWithQuotaProject)
122126
def with_quota_project(self, quota_project_id):
123127
return self.__class__(
124128
service_account_email=self._service_account_email,
125129
quota_project_id=quota_project_id,
130+
scopes=self._scopes,
131+
)
132+
133+
@_helpers.copy_docstring(credentials.Scoped)
134+
def with_scopes(self, scopes):
135+
# Compute Engine credentials can not be scoped (the metadata service
136+
# ignores the scopes parameter). App Engine, Cloud Run and Flex support
137+
# requesting scopes.
138+
return self.__class__(
139+
scopes=scopes,
140+
service_account_email=self._service_account_email,
141+
quota_project_id=self._quota_project_id,
126142
)
127143

128144

system_tests/noxfile.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -315,7 +315,7 @@ def default_explicit_service_account_async(session):
315315
session.env[EXPECT_PROJECT_ENV] = "1"
316316
session.install(*(TEST_DEPENDENCIES_SYNC + TEST_DEPENDENCIES_ASYNC))
317317
session.install(LIBRARY_DIR)
318-
session.run("pytest", "system_tests_async/test_default.py",
318+
session.run("pytest", "system_tests_async/test_default.py",
319319
"system_tests_async/test_id_token.py")
320320

321321

tests/compute_engine/test_credentials.py

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,8 @@ def test_default_state(self):
5555
assert not self.credentials.valid
5656
# Expiration hasn't been set yet
5757
assert not self.credentials.expired
58-
# Scopes aren't needed
59-
assert not self.credentials.requires_scopes
58+
# Scopes are needed
59+
assert self.credentials.requires_scopes
6060
# Service account email hasn't been populated
6161
assert self.credentials.service_account_email == "default"
6262
# No quota project
@@ -96,6 +96,45 @@ def test_refresh_success(self, get, utcnow):
9696
# expired)
9797
assert self.credentials.valid
9898

99+
@mock.patch(
100+
"google.auth._helpers.utcnow",
101+
return_value=datetime.datetime.min + _helpers.CLOCK_SKEW,
102+
)
103+
@mock.patch("google.auth.compute_engine._metadata.get", autospec=True)
104+
def test_refresh_success_with_scopes(self, get, utcnow):
105+
get.side_effect = [
106+
{
107+
# First request is for sevice account info.
108+
"email": "service-account@example.com",
109+
"scopes": ["one", "two"],
110+
},
111+
{
112+
# Second request is for the token.
113+
"access_token": "token",
114+
"expires_in": 500,
115+
},
116+
]
117+
118+
# Refresh credentials
119+
scopes = ["three", "four"]
120+
self.credentials = self.credentials.with_scopes(scopes)
121+
self.credentials.refresh(None)
122+
123+
# Check that the credentials have the token and proper expiration
124+
assert self.credentials.token == "token"
125+
assert self.credentials.expiry == (utcnow() + datetime.timedelta(seconds=500))
126+
127+
# Check the credential info
128+
assert self.credentials.service_account_email == "service-account@example.com"
129+
assert self.credentials._scopes == scopes
130+
131+
# Check that the credentials are valid (have a token and are not
132+
# expired)
133+
assert self.credentials.valid
134+
135+
kwargs = get.call_args[1]
136+
assert kwargs == {"params": {"scopes": "three,four"}}
137+
99138
@mock.patch("google.auth.compute_engine._metadata.get", autospec=True)
100139
def test_refresh_error(self, get):
101140
get.side_effect = exceptions.TransportError("http error")
@@ -138,6 +177,14 @@ def test_with_quota_project(self):
138177

139178
assert quota_project_creds._quota_project_id == "project-foo"
140179

180+
def test_with_scopes(self):
181+
assert self.credentials._scopes is None
182+
183+
scopes = ["one", "two"]
184+
self.credentials = self.credentials.with_scopes(scopes)
185+
186+
assert self.credentials._scopes == scopes
187+
141188

142189
class TestIDTokenCredentials(object):
143190
credentials = None

0 commit comments

Comments
 (0)