Skip to content
This repository was archived by the owner on Mar 6, 2026. It is now read-only.

Commit 2f5c3a6

Browse files
feat: define google.auth.downscoped.Credentials class (#801)
* feat: define `google.auth.downscoped.Credentials` class This is based on [Downscoping with Credential Access Boundaries](https://cloud.google.com/iam/docs/downscoping-short-lived-credentials). The new credentials are initialized mainly using elevated source credentials and a `google.auth.downscoped.CredentialAccessBoundary` instance. The credentials will then get access tokens from the source credentials and exchange them via the GCP STS token exchange endpoint using the provided credentials access boundary rules for downscoped access tokens. The new credentials will inherit the source credentials' scopes but the scopes are not exposed as we cannot always determine the scopes form the source credentials. * Fixes typos in comments. * Addresses review comments. * Moves all constants in the test file to module scope.
1 parent d3944af commit 2f5c3a6

2 files changed

Lines changed: 353 additions & 0 deletions

File tree

‎google/auth/downscoped.py‎

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,24 @@
4848
.. _Downscoping with Credential Access Boundaries: https://cloud.google.com/iam/docs/downscoping-short-lived-credentials
4949
"""
5050

51+
import datetime
52+
53+
from google.auth import _helpers
54+
from google.auth import credentials
55+
from google.oauth2 import sts
56+
5157
# The maximum number of access boundary rules a Credential Access Boundary can
5258
# contain.
5359
_MAX_ACCESS_BOUNDARY_RULES_COUNT = 10
60+
# The token exchange grant_type used for exchanging credentials.
61+
_STS_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange"
62+
# The token exchange requested_token_type. This is always an access_token.
63+
_STS_REQUESTED_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token"
64+
# The STS token URL used to exchanged a short lived access token for a downscoped one.
65+
_STS_TOKEN_URL = "https://sts.googleapis.com/v1/token"
66+
# The subject token type to use when exchanging a short lived access token for a
67+
# downscoped token.
68+
_STS_SUBJECT_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token"
5469

5570

5671
class CredentialAccessBoundary(object):
@@ -403,3 +418,74 @@ def to_json(self):
403418
if self.description:
404419
json["description"] = self.description
405420
return json
421+
422+
423+
class Credentials(credentials.CredentialsWithQuotaProject):
424+
"""Defines a set of Google credentials that are downscoped from an existing set
425+
of Google OAuth2 credentials. This is useful to restrict the Identity and Access
426+
Management (IAM) permissions that a short-lived credential can use.
427+
The common pattern of usage is to have a token broker with elevated access
428+
generate these downscoped credentials from higher access source credentials and
429+
pass the downscoped short-lived access tokens to a token consumer via some
430+
secure authenticated channel for limited access to Google Cloud Storage
431+
resources.
432+
"""
433+
434+
def __init__(
435+
self, source_credentials, credential_access_boundary, quota_project_id=None
436+
):
437+
"""Instantiates a downscoped credentials object using the provided source
438+
credentials and credential access boundary rules.
439+
To downscope permissions of a source credential, a Credential Access Boundary
440+
that specifies which resources the new credential can access, as well as an
441+
upper bound on the permissions that are available on each resource, has to be
442+
defined. A downscoped credential can then be instantiated using the source
443+
credential and the Credential Access Boundary.
444+
445+
Args:
446+
source_credentials (google.auth.credentials.Credentials): The source credentials
447+
to be downscoped based on the provided Credential Access Boundary rules.
448+
credential_access_boundary (google.auth.downscoped.CredentialAccessBoundary):
449+
The Credential Access Boundary which contains a list of access boundary
450+
rules. Each rule contains information on the resource that the rule applies to,
451+
the upper bound of the permissions that are available on that resource and an
452+
optional condition to further restrict permissions.
453+
quota_project_id (Optional[str]): The optional quota project ID.
454+
Raises:
455+
google.auth.exceptions.RefreshError: If the source credentials
456+
return an error on token refresh.
457+
google.auth.exceptions.OAuthError: If the STS token exchange
458+
endpoint returned an error during downscoped token generation.
459+
"""
460+
461+
super(Credentials, self).__init__()
462+
self._source_credentials = source_credentials
463+
self._credential_access_boundary = credential_access_boundary
464+
self._quota_project_id = quota_project_id
465+
self._sts_client = sts.Client(_STS_TOKEN_URL)
466+
467+
@_helpers.copy_docstring(credentials.Credentials)
468+
def refresh(self, request):
469+
# Generate an access token from the source credentials.
470+
self._source_credentials.refresh(request)
471+
now = _helpers.utcnow()
472+
# Exchange the access token for a downscoped access token.
473+
response_data = self._sts_client.exchange_token(
474+
request=request,
475+
grant_type=_STS_GRANT_TYPE,
476+
subject_token=self._source_credentials.token,
477+
subject_token_type=_STS_SUBJECT_TOKEN_TYPE,
478+
requested_token_type=_STS_REQUESTED_TOKEN_TYPE,
479+
additional_options=self._credential_access_boundary.to_json(),
480+
)
481+
self.token = response_data.get("access_token")
482+
lifetime = datetime.timedelta(seconds=response_data.get("expires_in"))
483+
self.expiry = now + lifetime
484+
485+
@_helpers.copy_docstring(credentials.CredentialsWithQuotaProject)
486+
def with_quota_project(self, quota_project_id):
487+
return self.__class__(
488+
self._source_credentials,
489+
self._credential_access_boundary,
490+
quota_project_id=quota_project_id,
491+
)

‎tests/test_downscoped.py‎

Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,19 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
import datetime
16+
import json
17+
18+
import mock
1519
import pytest
20+
from six.moves import http_client
21+
from six.moves import urllib
1622

23+
from google.auth import _helpers
24+
from google.auth import credentials
1725
from google.auth import downscoped
26+
from google.auth import exceptions
27+
from google.auth import transport
1828

1929

2030
EXPRESSION = (
@@ -36,6 +46,54 @@
3646
)
3747
OTHER_AVAILABLE_RESOURCE = "//storage.googleapis.com/projects/_/buckets/other-bucket"
3848
OTHER_AVAILABLE_PERMISSIONS = ["inRole:roles/storage.objectCreator"]
49+
QUOTA_PROJECT_ID = "QUOTA_PROJECT_ID"
50+
GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange"
51+
REQUESTED_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token"
52+
TOKEN_EXCHANGE_ENDPOINT = "https://sts.googleapis.com/v1/token"
53+
SUBJECT_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token"
54+
SUCCESS_RESPONSE = {
55+
"access_token": "ACCESS_TOKEN",
56+
"issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
57+
"token_type": "Bearer",
58+
"expires_in": 3600,
59+
}
60+
ERROR_RESPONSE = {
61+
"error": "invalid_grant",
62+
"error_description": "Subject token is invalid.",
63+
"error_uri": "https://tools.ietf.org/html/rfc6749",
64+
}
65+
CREDENTIAL_ACCESS_BOUNDARY_JSON = {
66+
"accessBoundary": {
67+
"accessBoundaryRules": [
68+
{
69+
"availablePermissions": AVAILABLE_PERMISSIONS,
70+
"availableResource": AVAILABLE_RESOURCE,
71+
"availabilityCondition": {
72+
"expression": EXPRESSION,
73+
"title": TITLE,
74+
"description": DESCRIPTION,
75+
},
76+
}
77+
]
78+
}
79+
}
80+
81+
82+
class SourceCredentials(credentials.Credentials):
83+
def __init__(self, raise_error=False):
84+
super(SourceCredentials, self).__init__()
85+
self._counter = 0
86+
self._raise_error = raise_error
87+
88+
def refresh(self, request):
89+
if self._raise_error:
90+
raise exceptions.RefreshError(
91+
"Failed to refresh access token in source credentials."
92+
)
93+
now = _helpers.utcnow()
94+
self._counter += 1
95+
self.token = "ACCESS_TOKEN_{}".format(self._counter)
96+
self.expiry = now + datetime.timedelta(seconds=3600)
3997

4098

4199
def make_availability_condition(expression, title=None, description=None):
@@ -383,3 +441,212 @@ def test_to_json(self):
383441
]
384442
}
385443
}
444+
445+
446+
class TestCredentials(object):
447+
@staticmethod
448+
def make_credentials(source_credentials=SourceCredentials(), quota_project_id=None):
449+
availability_condition = make_availability_condition(
450+
EXPRESSION, TITLE, DESCRIPTION
451+
)
452+
access_boundary_rule = make_access_boundary_rule(
453+
AVAILABLE_RESOURCE, AVAILABLE_PERMISSIONS, availability_condition
454+
)
455+
rules = [access_boundary_rule]
456+
credential_access_boundary = make_credential_access_boundary(rules)
457+
458+
return downscoped.Credentials(
459+
source_credentials, credential_access_boundary, quota_project_id
460+
)
461+
462+
@staticmethod
463+
def make_mock_request(data, status=http_client.OK):
464+
response = mock.create_autospec(transport.Response, instance=True)
465+
response.status = status
466+
response.data = json.dumps(data).encode("utf-8")
467+
468+
request = mock.create_autospec(transport.Request)
469+
request.return_value = response
470+
471+
return request
472+
473+
@staticmethod
474+
def assert_request_kwargs(request_kwargs, headers, request_data):
475+
"""Asserts the request was called with the expected parameters.
476+
"""
477+
assert request_kwargs["url"] == TOKEN_EXCHANGE_ENDPOINT
478+
assert request_kwargs["method"] == "POST"
479+
assert request_kwargs["headers"] == headers
480+
assert request_kwargs["body"] is not None
481+
body_tuples = urllib.parse.parse_qsl(request_kwargs["body"])
482+
for (k, v) in body_tuples:
483+
assert v.decode("utf-8") == request_data[k.decode("utf-8")]
484+
assert len(body_tuples) == len(request_data.keys())
485+
486+
def test_default_state(self):
487+
credentials = self.make_credentials()
488+
489+
# No token acquired yet.
490+
assert not credentials.token
491+
assert not credentials.valid
492+
# Expiration hasn't been set yet.
493+
assert not credentials.expiry
494+
assert not credentials.expired
495+
# No quota project ID set.
496+
assert not credentials.quota_project_id
497+
498+
def test_with_quota_project(self):
499+
credentials = self.make_credentials()
500+
501+
assert not credentials.quota_project_id
502+
503+
quota_project_creds = credentials.with_quota_project("project-foo")
504+
505+
assert quota_project_creds.quota_project_id == "project-foo"
506+
507+
@mock.patch("google.auth._helpers.utcnow", return_value=datetime.datetime.min)
508+
def test_refresh(self, unused_utcnow):
509+
response = SUCCESS_RESPONSE.copy()
510+
# Test custom expiration to confirm expiry is set correctly.
511+
response["expires_in"] = 2800
512+
expected_expiry = datetime.datetime.min + datetime.timedelta(
513+
seconds=response["expires_in"]
514+
)
515+
headers = {"Content-Type": "application/x-www-form-urlencoded"}
516+
request_data = {
517+
"grant_type": GRANT_TYPE,
518+
"subject_token": "ACCESS_TOKEN_1",
519+
"subject_token_type": SUBJECT_TOKEN_TYPE,
520+
"requested_token_type": REQUESTED_TOKEN_TYPE,
521+
"options": urllib.parse.quote(json.dumps(CREDENTIAL_ACCESS_BOUNDARY_JSON)),
522+
}
523+
request = self.make_mock_request(status=http_client.OK, data=response)
524+
source_credentials = SourceCredentials()
525+
credentials = self.make_credentials(source_credentials=source_credentials)
526+
527+
# Spy on calls to source credentials refresh to confirm the expected request
528+
# instance is used.
529+
with mock.patch.object(
530+
source_credentials, "refresh", wraps=source_credentials.refresh
531+
) as wrapped_souce_cred_refresh:
532+
credentials.refresh(request)
533+
534+
self.assert_request_kwargs(request.call_args[1], headers, request_data)
535+
assert credentials.valid
536+
assert credentials.expiry == expected_expiry
537+
assert not credentials.expired
538+
assert credentials.token == response["access_token"]
539+
# Confirm source credentials called with the same request instance.
540+
wrapped_souce_cred_refresh.assert_called_with(request)
541+
542+
def test_refresh_token_exchange_error(self):
543+
request = self.make_mock_request(
544+
status=http_client.BAD_REQUEST, data=ERROR_RESPONSE
545+
)
546+
credentials = self.make_credentials()
547+
548+
with pytest.raises(exceptions.OAuthError) as excinfo:
549+
credentials.refresh(request)
550+
551+
assert excinfo.match(
552+
r"Error code invalid_grant: Subject token is invalid. - https://tools.ietf.org/html/rfc6749"
553+
)
554+
assert not credentials.expired
555+
assert credentials.token is None
556+
557+
def test_refresh_source_credentials_refresh_error(self):
558+
# Initialize downscoped credentials with source credentials that raise
559+
# an error on refresh.
560+
credentials = self.make_credentials(
561+
source_credentials=SourceCredentials(raise_error=True)
562+
)
563+
564+
with pytest.raises(exceptions.RefreshError) as excinfo:
565+
credentials.refresh(mock.sentinel.request)
566+
567+
assert excinfo.match(r"Failed to refresh access token in source credentials.")
568+
assert not credentials.expired
569+
assert credentials.token is None
570+
571+
def test_apply_without_quota_project_id(self):
572+
headers = {}
573+
request = self.make_mock_request(status=http_client.OK, data=SUCCESS_RESPONSE)
574+
credentials = self.make_credentials()
575+
576+
credentials.refresh(request)
577+
credentials.apply(headers)
578+
579+
assert headers == {
580+
"authorization": "Bearer {}".format(SUCCESS_RESPONSE["access_token"])
581+
}
582+
583+
def test_apply_with_quota_project_id(self):
584+
headers = {"other": "header-value"}
585+
request = self.make_mock_request(status=http_client.OK, data=SUCCESS_RESPONSE)
586+
credentials = self.make_credentials(quota_project_id=QUOTA_PROJECT_ID)
587+
588+
credentials.refresh(request)
589+
credentials.apply(headers)
590+
591+
assert headers == {
592+
"other": "header-value",
593+
"authorization": "Bearer {}".format(SUCCESS_RESPONSE["access_token"]),
594+
"x-goog-user-project": QUOTA_PROJECT_ID,
595+
}
596+
597+
def test_before_request(self):
598+
headers = {"other": "header-value"}
599+
request = self.make_mock_request(status=http_client.OK, data=SUCCESS_RESPONSE)
600+
credentials = self.make_credentials()
601+
602+
# First call should call refresh, setting the token.
603+
credentials.before_request(request, "POST", "https://example.com/api", headers)
604+
605+
assert headers == {
606+
"other": "header-value",
607+
"authorization": "Bearer {}".format(SUCCESS_RESPONSE["access_token"]),
608+
}
609+
610+
# Second call shouldn't call refresh (request should be untouched).
611+
credentials.before_request(
612+
mock.sentinel.request, "POST", "https://example.com/api", headers
613+
)
614+
615+
assert headers == {
616+
"other": "header-value",
617+
"authorization": "Bearer {}".format(SUCCESS_RESPONSE["access_token"]),
618+
}
619+
620+
@mock.patch("google.auth._helpers.utcnow")
621+
def test_before_request_expired(self, utcnow):
622+
headers = {}
623+
request = self.make_mock_request(status=http_client.OK, data=SUCCESS_RESPONSE)
624+
credentials = self.make_credentials()
625+
credentials.token = "token"
626+
utcnow.return_value = datetime.datetime.min
627+
# Set the expiration to one second more than now plus the clock skew
628+
# accommodation. These credentials should be valid.
629+
credentials.expiry = (
630+
datetime.datetime.min + _helpers.CLOCK_SKEW + datetime.timedelta(seconds=1)
631+
)
632+
633+
assert credentials.valid
634+
assert not credentials.expired
635+
636+
credentials.before_request(request, "POST", "https://example.com/api", headers)
637+
638+
# Cached token should be used.
639+
assert headers == {"authorization": "Bearer token"}
640+
641+
# Next call should simulate 1 second passed.
642+
utcnow.return_value = datetime.datetime.min + datetime.timedelta(seconds=1)
643+
644+
assert not credentials.valid
645+
assert credentials.expired
646+
647+
credentials.before_request(request, "POST", "https://example.com/api", headers)
648+
649+
# New token should be retrieved.
650+
assert headers == {
651+
"authorization": "Bearer {}".format(SUCCESS_RESPONSE["access_token"])
652+
}

0 commit comments

Comments
 (0)