From 2502fd7398a089b8795f3f5a9b5c1fa48e373044 Mon Sep 17 00:00:00 2001 From: Sijun Liu Date: Fri, 5 May 2023 21:52:57 -0700 Subject: [PATCH 1/5] feat: universe domain support for service account --- google/oauth2/service_account.py | 186 +++++++++++------------- tests/data/service_account_non_gdu.json | 15 ++ tests/oauth2/test_service_account.py | 94 +++++++++++- 3 files changed, 191 insertions(+), 104 deletions(-) create mode 100644 tests/data/service_account_non_gdu.json diff --git a/google/oauth2/service_account.py b/google/oauth2/service_account.py index 37e1e568a..8ad75e14a 100644 --- a/google/oauth2/service_account.py +++ b/google/oauth2/service_account.py @@ -76,10 +76,12 @@ from google.auth import _helpers from google.auth import _service_account_info from google.auth import credentials +from google.auth import exceptions from google.auth import jwt from google.oauth2 import _client _DEFAULT_TOKEN_LIFETIME_SECS = 3600 # 1 hour in seconds +_DEFAULT_UNIVERSE_DOMAIN = "googleapis.com" _GOOGLE_OAUTH2_TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token" @@ -136,6 +138,7 @@ def __init__( quota_project_id=None, additional_claims=None, always_use_jwt_access=False, + universe_domain=_DEFAULT_UNIVERSE_DOMAIN, ): """ Args: @@ -156,6 +159,9 @@ def __init__( the JWT assertion used in the authorization grant. always_use_jwt_access (Optional[bool]): Whether self signed JWT should be always used. + universe_domain (Optional[str]): The universe domain. The default + universe domain is googleapis.com. If this value is the default + value, then self signed jwt will be used for token refresh. .. note:: Typically one of the helper constructors :meth:`from_service_account_file` or @@ -173,6 +179,10 @@ def __init__( self._quota_project_id = quota_project_id self._token_uri = token_uri self._always_use_jwt_access = always_use_jwt_access + self._universe_domain = universe_domain + + if universe_domain != _DEFAULT_UNIVERSE_DOMAIN: + self._always_use_jwt_access = True self._jwt_credentials = None @@ -202,6 +212,7 @@ def _from_signer_and_info(cls, signer, info, **kwargs): service_account_email=info["client_email"], token_uri=info["token_uri"], project_id=info.get("project_id"), + universe_domain=info.get("universe_domain", _DEFAULT_UNIVERSE_DOMAIN), **kwargs ) @@ -262,20 +273,28 @@ def requires_scopes(self): """ return True if not self._scopes else False - @_helpers.copy_docstring(credentials.Scoped) - def with_scopes(self, scopes, default_scopes=None): - return self.__class__( + def _make_copy(self): + cred = self.__class__( self._signer, service_account_email=self._service_account_email, - scopes=scopes, - default_scopes=default_scopes, + scopes=self._scopes, + default_scopes=self._default_scopes, token_uri=self._token_uri, subject=self._subject, project_id=self._project_id, quota_project_id=self._quota_project_id, additional_claims=self._additional_claims.copy(), always_use_jwt_access=self._always_use_jwt_access, + universe_domain=self._universe_domain, ) + return cred + + @_helpers.copy_docstring(credentials.Scoped) + def with_scopes(self, scopes, default_scopes=None): + cred = self._make_copy() + cred._scopes = scopes + cred._default_scopes = default_scopes + return cred def with_always_use_jwt_access(self, always_use_jwt_access): """Create a copy of these credentials with the specified always_use_jwt_access value. @@ -287,18 +306,9 @@ def with_always_use_jwt_access(self, always_use_jwt_access): google.auth.service_account.Credentials: A new credentials instance. """ - return self.__class__( - self._signer, - service_account_email=self._service_account_email, - scopes=self._scopes, - default_scopes=self._default_scopes, - token_uri=self._token_uri, - subject=self._subject, - project_id=self._project_id, - quota_project_id=self._quota_project_id, - additional_claims=self._additional_claims.copy(), - always_use_jwt_access=always_use_jwt_access, - ) + cred = self._make_copy() + cred._always_use_jwt_access = always_use_jwt_access + return cred def with_subject(self, subject): """Create a copy of these credentials with the specified subject. @@ -310,18 +320,9 @@ def with_subject(self, subject): google.auth.service_account.Credentials: A new credentials instance. """ - return self.__class__( - self._signer, - service_account_email=self._service_account_email, - scopes=self._scopes, - default_scopes=self._default_scopes, - token_uri=self._token_uri, - subject=subject, - project_id=self._project_id, - quota_project_id=self._quota_project_id, - additional_claims=self._additional_claims.copy(), - always_use_jwt_access=self._always_use_jwt_access, - ) + cred = self._make_copy() + cred._subject = subject + return cred def with_claims(self, additional_claims): """Returns a copy of these credentials with modified claims. @@ -337,51 +338,21 @@ def with_claims(self, additional_claims): """ new_additional_claims = copy.deepcopy(self._additional_claims) new_additional_claims.update(additional_claims or {}) - - return self.__class__( - self._signer, - service_account_email=self._service_account_email, - scopes=self._scopes, - default_scopes=self._default_scopes, - token_uri=self._token_uri, - subject=self._subject, - project_id=self._project_id, - quota_project_id=self._quota_project_id, - additional_claims=new_additional_claims, - always_use_jwt_access=self._always_use_jwt_access, - ) + cred = self._make_copy() + cred._additional_claims = new_additional_claims + return cred @_helpers.copy_docstring(credentials.CredentialsWithQuotaProject) def with_quota_project(self, quota_project_id): - - return self.__class__( - self._signer, - service_account_email=self._service_account_email, - default_scopes=self._default_scopes, - scopes=self._scopes, - token_uri=self._token_uri, - subject=self._subject, - project_id=self._project_id, - quota_project_id=quota_project_id, - additional_claims=self._additional_claims.copy(), - always_use_jwt_access=self._always_use_jwt_access, - ) + cred = self._make_copy() + cred._quota_project_id = quota_project_id + return cred @_helpers.copy_docstring(credentials.CredentialsWithTokenUri) def with_token_uri(self, token_uri): - - return self.__class__( - self._signer, - service_account_email=self._service_account_email, - default_scopes=self._default_scopes, - scopes=self._scopes, - token_uri=token_uri, - subject=self._subject, - project_id=self._project_id, - quota_project_id=self._quota_project_id, - additional_claims=self._additional_claims.copy(), - always_use_jwt_access=self._always_use_jwt_access, - ) + cred = self._make_copy() + cred._token_uri = token_uri + return cred def _make_authorization_grant_assertion(self): """Create the OAuth 2.0 assertion. @@ -418,6 +389,18 @@ def _make_authorization_grant_assertion(self): @_helpers.copy_docstring(credentials.Credentials) def refresh(self, request): + if ( + self._universe_domain != _DEFAULT_UNIVERSE_DOMAIN + and not self._jwt_credentials + ): + raise exceptions.RefreshError( + "self._jwt_credentials is missing for non-default universe domain" + ) + if self._universe_domain != _DEFAULT_UNIVERSE_DOMAIN and self._subject: + raise exceptions.RefreshError( + "domain wide delegation is not supported for non-default universe domain" + ) + # Since domain wide delegation doesn't work with self signed JWT. If # subject exists, then we should not use self signed JWT. if self._subject is None and self._jwt_credentials is not None: @@ -544,6 +527,7 @@ def __init__( target_audience, additional_claims=None, quota_project_id=None, + universe_domain=_DEFAULT_UNIVERSE_DOMAIN, ): """ Args: @@ -556,6 +540,11 @@ def __init__( additional_claims (Mapping[str, str]): Any additional claims for the JWT assertion used in the authorization grant. quota_project_id (Optional[str]): The project ID used for quota and billing. + universe_domain (Optional[str]): The universe domain. The default + universe domain is googleapis.com. If this value is the default + value, then IAM ID token endponint will be used for token + refresh. Note that iam.serviceAccountTokenCreator role is + required to use the IAM endpoint. .. note:: Typically one of the helper constructors :meth:`from_service_account_file` or :meth:`from_service_account_info` are used instead of calling the @@ -568,6 +557,9 @@ def __init__( self._target_audience = target_audience self._quota_project_id = quota_project_id self._use_iam_endpoint = False + self._universe_domain = universe_domain + if universe_domain != _DEFAULT_UNIVERSE_DOMAIN: + self._use_iam_endpoint = True if additional_claims is not None: self._additional_claims = additional_claims @@ -592,6 +584,8 @@ def _from_signer_and_info(cls, signer, info, **kwargs): """ kwargs.setdefault("service_account_email", info["client_email"]) kwargs.setdefault("token_uri", info["token_uri"]) + if "universe_domain" in info: + kwargs["universe_domain"] = info["universe_domain"] return cls(signer, **kwargs) @classmethod @@ -632,6 +626,20 @@ def from_service_account_file(cls, filename, **kwargs): ) return cls._from_signer_and_info(signer, info, **kwargs) + def _make_copy(self): + cred = self.__class__( + self._signer, + service_account_email=self._service_account_email, + token_uri=self._token_uri, + target_audience=self._target_audience, + additional_claims=self._additional_claims.copy(), + quota_project_id=self.quota_project_id, + universe_domain=self._universe_domain, + ) + # _use_iam_endpoint is not exposed in the constructor + cred._use_iam_endpoint = self._use_iam_endpoint + return cred + def with_target_audience(self, target_audience): """Create a copy of these credentials with the specified target audience. @@ -644,14 +652,9 @@ def with_target_audience(self, target_audience): google.auth.service_account.IDTokenCredentials: A new credentials instance. """ - return self.__class__( - self._signer, - service_account_email=self._service_account_email, - token_uri=self._token_uri, - target_audience=target_audience, - additional_claims=self._additional_claims.copy(), - quota_project_id=self.quota_project_id, - ) + cred = self._make_copy() + cred._target_audience = target_audience + return cred def _with_use_iam_endpoint(self, use_iam_endpoint): """Create a copy of these credentials with the use_iam_endpoint value. @@ -667,38 +670,21 @@ def _with_use_iam_endpoint(self, use_iam_endpoint): google.auth.service_account.IDTokenCredentials: A new credentials instance. """ - cred = self.__class__( - self._signer, - service_account_email=self._service_account_email, - token_uri=self._token_uri, - target_audience=self._target_audience, - additional_claims=self._additional_claims.copy(), - quota_project_id=self.quota_project_id, - ) + cred = self._make_copy() cred._use_iam_endpoint = use_iam_endpoint return cred @_helpers.copy_docstring(credentials.CredentialsWithQuotaProject) def with_quota_project(self, quota_project_id): - return self.__class__( - self._signer, - service_account_email=self._service_account_email, - token_uri=self._token_uri, - target_audience=self._target_audience, - additional_claims=self._additional_claims.copy(), - quota_project_id=quota_project_id, - ) + cred = self._make_copy() + cred._quota_project_id = quota_project_id + return cred @_helpers.copy_docstring(credentials.CredentialsWithTokenUri) def with_token_uri(self, token_uri): - return self.__class__( - self._signer, - service_account_email=self._service_account_email, - token_uri=token_uri, - target_audience=self._target_audience, - additional_claims=self._additional_claims.copy(), - quota_project_id=self._quota_project_id, - ) + cred = self._make_copy() + cred._token_uri = token_uri + return cred def _make_authorization_grant_assertion(self): """Create the OAuth 2.0 assertion. diff --git a/tests/data/service_account_non_gdu.json b/tests/data/service_account_non_gdu.json new file mode 100644 index 000000000..976184f8c --- /dev/null +++ b/tests/data/service_account_non_gdu.json @@ -0,0 +1,15 @@ +{ + "type": "service_account", + "universe_domain": "universe.foo", + "project_id": "example_project", + "private_key_id": "1", + "private_key": "-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA4ej0p7bQ7L/r4rVGUz9RN4VQWoej1Bg1mYWIDYslvKrk1gpj\n7wZgkdmM7oVK2OfgrSj/FCTkInKPqaCR0gD7K80q+mLBrN3PUkDrJQZpvRZIff3/\nxmVU1WeruQLFJjnFb2dqu0s/FY/2kWiJtBCakXvXEOb7zfbINuayL+MSsCGSdVYs\nSliS5qQpgyDap+8b5fpXZVJkq92hrcNtbkg7hCYUJczt8n9hcCTJCfUpApvaFQ18\npe+zpyl4+WzkP66I28hniMQyUlA1hBiskT7qiouq0m8IOodhv2fagSZKjOTTU2xk\nSBc//fy3ZpsL7WqgsZS7Q+0VRK8gKfqkxg5OYQIDAQABAoIBAQDGGHzQxGKX+ANk\nnQi53v/c6632dJKYXVJC+PDAz4+bzU800Y+n/bOYsWf/kCp94XcG4Lgsdd0Gx+Zq\nHD9CI1IcqqBRR2AFscsmmX6YzPLTuEKBGMW8twaYy3utlFxElMwoUEsrSWRcCA1y\nnHSDzTt871c7nxCXHxuZ6Nm/XCL7Bg8uidRTSC1sQrQyKgTPhtQdYrPQ4WZ1A4J9\nIisyDYmZodSNZe5P+LTJ6M1SCgH8KH9ZGIxv3diMwzNNpk3kxJc9yCnja4mjiGE2\nYCNusSycU5IhZwVeCTlhQGcNeV/skfg64xkiJE34c2y2ttFbdwBTPixStGaF09nU\nZ422D40BAoGBAPvVyRRsC3BF+qZdaSMFwI1yiXY7vQw5+JZh01tD28NuYdRFzjcJ\nvzT2n8LFpj5ZfZFvSMLMVEFVMgQvWnN0O6xdXvGov6qlRUSGaH9u+TCPNnIldjMP\nB8+xTwFMqI7uQr54wBB+Poq7dVRP+0oHb0NYAwUBXoEuvYo3c/nDoRcZAoGBAOWl\naLHjMv4CJbArzT8sPfic/8waSiLV9Ixs3Re5YREUTtnLq7LoymqB57UXJB3BNz/2\neCueuW71avlWlRtE/wXASj5jx6y5mIrlV4nZbVuyYff0QlcG+fgb6pcJQuO9DxMI\naqFGrWP3zye+LK87a6iR76dS9vRU+bHZpSVvGMKJAoGAFGt3TIKeQtJJyqeUWNSk\nklORNdcOMymYMIlqG+JatXQD1rR6ThgqOt8sgRyJqFCVT++YFMOAqXOBBLnaObZZ\nCFbh1fJ66BlSjoXff0W+SuOx5HuJJAa5+WtFHrPajwxeuRcNa8jwxUsB7n41wADu\nUqWWSRedVBg4Ijbw3nWwYDECgYB0pLew4z4bVuvdt+HgnJA9n0EuYowVdadpTEJg\nsoBjNHV4msLzdNqbjrAqgz6M/n8Ztg8D2PNHMNDNJPVHjJwcR7duSTA6w2p/4k28\nbvvk/45Ta3XmzlxZcZSOct3O31Cw0i2XDVc018IY5be8qendDYM08icNo7vQYkRH\n504kQQKBgQDjx60zpz8ozvm1XAj0wVhi7GwXe+5lTxiLi9Fxq721WDxPMiHDW2XL\nYXfFVy/9/GIMvEiGYdmarK1NW+VhWl1DC5xhDg0kvMfxplt4tynoq1uTsQTY31Mx\nBeF5CT/JuNYk3bEBF0H/Q3VGO1/ggVS+YezdFbLWIRoMnLj6XCFEGg==\n-----END RSA PRIVATE KEY-----\n", + "client_email": "testsa@foo.iam.gserviceaccount.com", + "client_id": "1234", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.universe.foo/token", + "auth_provider_x509_cert_url": "https://www.universe.foo/oauth2/v1/certs", + "client_x509_cert_url": "https://www.universe.foo/robot/v1/metadata/x509/foo.iam.gserviceaccount.com" +} + + \ No newline at end of file diff --git a/tests/oauth2/test_service_account.py b/tests/oauth2/test_service_account.py index 6b0d1dcce..32b506733 100644 --- a/tests/oauth2/test_service_account.py +++ b/tests/oauth2/test_service_account.py @@ -17,9 +17,11 @@ import os import mock +import pytest from google.auth import _helpers from google.auth import crypt +from google.auth import exceptions from google.auth import jwt from google.auth import transport from google.oauth2 import service_account @@ -37,10 +39,17 @@ OTHER_CERT_BYTES = fh.read() SERVICE_ACCOUNT_JSON_FILE = os.path.join(DATA_DIR, "service_account.json") +SERVICE_ACCOUNT_NON_GDU_JSON_FILE = os.path.join( + DATA_DIR, "service_account_non_gdu.json" +) +FAKE_UNIVERSE_DOMAIN = "universe.foo" with open(SERVICE_ACCOUNT_JSON_FILE, "rb") as fh: SERVICE_ACCOUNT_INFO = json.load(fh) +with open(SERVICE_ACCOUNT_NON_GDU_JSON_FILE, "rb") as fh: + SERVICE_ACCOUNT_INFO_NON_GDU = json.load(fh) + SIGNER = crypt.RSASigner.from_string(PRIVATE_KEY_BYTES, "1") @@ -49,9 +58,12 @@ class TestCredentials(object): TOKEN_URI = "https://example.com/oauth2/token" @classmethod - def make_credentials(cls): + def make_credentials(cls, universe_domain=service_account._DEFAULT_UNIVERSE_DOMAIN): return service_account.Credentials( - SIGNER, cls.SERVICE_ACCOUNT_EMAIL, cls.TOKEN_URI + SIGNER, + cls.SERVICE_ACCOUNT_EMAIL, + cls.TOKEN_URI, + universe_domain=universe_domain, ) def test_from_service_account_info(self): @@ -62,6 +74,16 @@ def test_from_service_account_info(self): assert credentials._signer.key_id == SERVICE_ACCOUNT_INFO["private_key_id"] assert credentials.service_account_email == SERVICE_ACCOUNT_INFO["client_email"] assert credentials._token_uri == SERVICE_ACCOUNT_INFO["token_uri"] + assert credentials._universe_domain == service_account._DEFAULT_UNIVERSE_DOMAIN + assert not credentials._always_use_jwt_access + + def test_from_service_account_info_non_gdu(self): + credentials = service_account.Credentials.from_service_account_info( + SERVICE_ACCOUNT_INFO_NON_GDU + ) + + assert credentials._universe_domain == FAKE_UNIVERSE_DOMAIN + assert credentials._always_use_jwt_access def test_from_service_account_info_args(self): info = SERVICE_ACCOUNT_INFO.copy() @@ -80,6 +102,7 @@ def test_from_service_account_info_args(self): assert credentials._scopes == scopes assert credentials._subject == subject assert credentials._additional_claims == additional_claims + assert not credentials._always_use_jwt_access def test_from_service_account_file(self): info = SERVICE_ACCOUNT_INFO.copy() @@ -93,6 +116,20 @@ def test_from_service_account_file(self): assert credentials._signer.key_id == info["private_key_id"] assert credentials._token_uri == info["token_uri"] + def test_from_service_account_file_non_gdu(self): + info = SERVICE_ACCOUNT_INFO_NON_GDU.copy() + + credentials = service_account.Credentials.from_service_account_file( + SERVICE_ACCOUNT_NON_GDU_JSON_FILE + ) + + assert credentials.service_account_email == info["client_email"] + assert credentials.project_id == info["project_id"] + assert credentials._signer.key_id == info["private_key_id"] + assert credentials._token_uri == info["token_uri"] + assert credentials._universe_domain == FAKE_UNIVERSE_DOMAIN + assert credentials._always_use_jwt_access + def test_from_service_account_file_args(self): info = SERVICE_ACCOUNT_INFO.copy() scopes = ["email", "profile"] @@ -464,6 +501,22 @@ def test_refresh_jwt_not_used_for_domain_wide_delegation( assert jwt_grant.called assert not self_signed_jwt_refresh.called + def test_refresh_non_gdu_missing_jwt_credentials(self): + credentials = self.make_credentials(universe_domain="foo") + + with pytest.raises(exceptions.RefreshError) as excinfo: + credentials.refresh(None) + assert excinfo.match("self._jwt_credentials is missing") + + def test_refresh_non_gdu_domain_wide_delegation_not_supported(self): + credentials = self.make_credentials(universe_domain="foo") + credentials._subject = "bar@example.com" + credentials._create_self_signed_jwt("https://pubsub.googleapis.com") + + with pytest.raises(exceptions.RefreshError) as excinfo: + credentials.refresh(None) + assert excinfo.match("domain wide delegation is not supported") + class TestIDTokenCredentials(object): SERVICE_ACCOUNT_EMAIL = "service-account@example.com" @@ -471,9 +524,13 @@ class TestIDTokenCredentials(object): TARGET_AUDIENCE = "https://example.com" @classmethod - def make_credentials(cls): + def make_credentials(cls, universe_domain=service_account._DEFAULT_UNIVERSE_DOMAIN): return service_account.IDTokenCredentials( - SIGNER, cls.SERVICE_ACCOUNT_EMAIL, cls.TOKEN_URI, cls.TARGET_AUDIENCE + SIGNER, + cls.SERVICE_ACCOUNT_EMAIL, + cls.TOKEN_URI, + cls.TARGET_AUDIENCE, + universe_domain=universe_domain, ) def test_from_service_account_info(self): @@ -487,6 +544,22 @@ def test_from_service_account_info(self): assert credentials._target_audience == self.TARGET_AUDIENCE assert not credentials._use_iam_endpoint + def test_from_service_account_info_non_gdu(self): + credentials = service_account.IDTokenCredentials.from_service_account_info( + SERVICE_ACCOUNT_INFO_NON_GDU, target_audience=self.TARGET_AUDIENCE + ) + + assert ( + credentials._signer.key_id == SERVICE_ACCOUNT_INFO_NON_GDU["private_key_id"] + ) + assert ( + credentials.service_account_email + == SERVICE_ACCOUNT_INFO_NON_GDU["client_email"] + ) + assert credentials._token_uri == SERVICE_ACCOUNT_INFO_NON_GDU["token_uri"] + assert credentials._target_audience == self.TARGET_AUDIENCE + assert credentials._use_iam_endpoint + def test_from_service_account_file(self): info = SERVICE_ACCOUNT_INFO.copy() @@ -500,6 +573,19 @@ def test_from_service_account_file(self): assert credentials._target_audience == self.TARGET_AUDIENCE assert not credentials._use_iam_endpoint + def test_from_service_account_file_non_gdu(self): + info = SERVICE_ACCOUNT_INFO_NON_GDU.copy() + + credentials = service_account.IDTokenCredentials.from_service_account_file( + SERVICE_ACCOUNT_NON_GDU_JSON_FILE, target_audience=self.TARGET_AUDIENCE + ) + + assert credentials.service_account_email == info["client_email"] + assert credentials._signer.key_id == info["private_key_id"] + assert credentials._token_uri == info["token_uri"] + assert credentials._target_audience == self.TARGET_AUDIENCE + assert credentials._use_iam_endpoint + def test_default_state(self): credentials = self.make_credentials() assert not credentials.valid From 12af50c026da18c55eff62a2a1b69f4897b9fa4d Mon Sep 17 00:00:00 2001 From: Sijun Liu Date: Fri, 5 May 2023 23:06:36 -0700 Subject: [PATCH 2/5] update --- tests/oauth2/test_service_account.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/oauth2/test_service_account.py b/tests/oauth2/test_service_account.py index 32b506733..36125066c 100644 --- a/tests/oauth2/test_service_account.py +++ b/tests/oauth2/test_service_account.py @@ -17,7 +17,7 @@ import os import mock -import pytest +import pytest # type: ignore from google.auth import _helpers from google.auth import crypt From fac2925b37eb7c07beb6661c1df612d1361c3d9c Mon Sep 17 00:00:00 2001 From: Sijun Liu Date: Tue, 9 May 2023 17:14:12 -0700 Subject: [PATCH 3/5] update --- google/oauth2/service_account.py | 20 ++++++++++++++------ tests/oauth2/test_service_account.py | 16 ++++++++++++++++ 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/google/oauth2/service_account.py b/google/oauth2/service_account.py index 8ad75e14a..edb3088f1 100644 --- a/google/oauth2/service_account.py +++ b/google/oauth2/service_account.py @@ -159,7 +159,7 @@ def __init__( the JWT assertion used in the authorization grant. always_use_jwt_access (Optional[bool]): Whether self signed JWT should be always used. - universe_domain (Optional[str]): The universe domain. The default + universe_domain (str): The universe domain. The default universe domain is googleapis.com. If this value is the default value, then self signed jwt will be used for token refresh. @@ -179,7 +179,10 @@ def __init__( self._quota_project_id = quota_project_id self._token_uri = token_uri self._always_use_jwt_access = always_use_jwt_access - self._universe_domain = universe_domain + if not universe_domain: + self._universe_domain = _DEFAULT_UNIVERSE_DOMAIN + else: + self._universe_domain = universe_domain if universe_domain != _DEFAULT_UNIVERSE_DOMAIN: self._always_use_jwt_access = True @@ -277,8 +280,8 @@ def _make_copy(self): cred = self.__class__( self._signer, service_account_email=self._service_account_email, - scopes=self._scopes, - default_scopes=self._default_scopes, + scopes=copy.copy(self._scopes), + default_scopes=copy.copy(self._default_scopes), token_uri=self._token_uri, subject=self._subject, project_id=self._project_id, @@ -540,7 +543,7 @@ def __init__( additional_claims (Mapping[str, str]): Any additional claims for the JWT assertion used in the authorization grant. quota_project_id (Optional[str]): The project ID used for quota and billing. - universe_domain (Optional[str]): The universe domain. The default + universe_domain (str): The universe domain. The default universe domain is googleapis.com. If this value is the default value, then IAM ID token endponint will be used for token refresh. Note that iam.serviceAccountTokenCreator role is @@ -557,7 +560,12 @@ def __init__( self._target_audience = target_audience self._quota_project_id = quota_project_id self._use_iam_endpoint = False - self._universe_domain = universe_domain + + if not universe_domain: + self._universe_domain = _DEFAULT_UNIVERSE_DOMAIN + else: + self._universe_domain = universe_domain + if universe_domain != _DEFAULT_UNIVERSE_DOMAIN: self._use_iam_endpoint = True diff --git a/tests/oauth2/test_service_account.py b/tests/oauth2/test_service_account.py index 36125066c..11b41c47f 100644 --- a/tests/oauth2/test_service_account.py +++ b/tests/oauth2/test_service_account.py @@ -66,6 +66,12 @@ def make_credentials(cls, universe_domain=service_account._DEFAULT_UNIVERSE_DOMA universe_domain=universe_domain, ) + def test_constructor_no_universe_domain(self): + credentials = service_account.Credentials( + SIGNER, self.SERVICE_ACCOUNT_EMAIL, self.TOKEN_URI, universe_domain=None + ) + assert credentials._universe_domain == service_account._DEFAULT_UNIVERSE_DOMAIN + def test_from_service_account_info(self): credentials = service_account.Credentials.from_service_account_info( SERVICE_ACCOUNT_INFO @@ -533,6 +539,16 @@ def make_credentials(cls, universe_domain=service_account._DEFAULT_UNIVERSE_DOMA universe_domain=universe_domain, ) + def test_constructor_no_universe_domain(self): + credentials = service_account.IDTokenCredentials( + SIGNER, + self.SERVICE_ACCOUNT_EMAIL, + self.TOKEN_URI, + self.TARGET_AUDIENCE, + universe_domain=None, + ) + assert credentials._universe_domain == service_account._DEFAULT_UNIVERSE_DOMAIN + def test_from_service_account_info(self): credentials = service_account.IDTokenCredentials.from_service_account_info( SERVICE_ACCOUNT_INFO, target_audience=self.TARGET_AUDIENCE From 39c71124bd8f46bc5723e46c106c38eecba9a861 Mon Sep 17 00:00:00 2001 From: Sijun Liu Date: Tue, 9 May 2023 21:11:46 -0700 Subject: [PATCH 4/5] update --- google/oauth2/service_account.py | 29 ++++++++++++++++++++++------ tests/oauth2/test_service_account.py | 18 +++++++++++++++++ 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/google/oauth2/service_account.py b/google/oauth2/service_account.py index edb3088f1..bb2670525 100644 --- a/google/oauth2/service_account.py +++ b/google/oauth2/service_account.py @@ -160,8 +160,8 @@ def __init__( always_use_jwt_access (Optional[bool]): Whether self signed JWT should be always used. universe_domain (str): The universe domain. The default - universe domain is googleapis.com. If this value is the default - value, then self signed jwt will be used for token refresh. + universe domain is googleapis.com. For default value self + signed jwt is used for token refresh. .. note:: Typically one of the helper constructors :meth:`from_service_account_file` or @@ -308,8 +308,18 @@ def with_always_use_jwt_access(self, always_use_jwt_access): Returns: google.auth.service_account.Credentials: A new credentials instance. + Raises: + google.auth.exceptions.InvalidValue: If the universe domain is not + default and always_use_jwt_access is False. """ cred = self._make_copy() + if ( + cred._universe_domain != _DEFAULT_UNIVERSE_DOMAIN + and not always_use_jwt_access + ): + raise exceptions.InvalidValue( + "always_use_jwt_access should be True for non-default universe domain" + ) cred._always_use_jwt_access = always_use_jwt_access return cred @@ -544,10 +554,10 @@ def __init__( the JWT assertion used in the authorization grant. quota_project_id (Optional[str]): The project ID used for quota and billing. universe_domain (str): The universe domain. The default - universe domain is googleapis.com. If this value is the default - value, then IAM ID token endponint will be used for token - refresh. Note that iam.serviceAccountTokenCreator role is - required to use the IAM endpoint. + universe domain is googleapis.com. For default value IAM ID + token endponint is used for token refresh. Note that + iam.serviceAccountTokenCreator role is required to use the IAM + endpoint. .. note:: Typically one of the helper constructors :meth:`from_service_account_file` or :meth:`from_service_account_info` are used instead of calling the @@ -677,8 +687,15 @@ def _with_use_iam_endpoint(self, use_iam_endpoint): Returns: google.auth.service_account.IDTokenCredentials: A new credentials instance. + Raises: + google.auth.exceptions.InvalidValue: If the universe domain is not + default and use_iam_endpoint is False. """ cred = self._make_copy() + if cred._universe_domain != _DEFAULT_UNIVERSE_DOMAIN and not use_iam_endpoint: + raise exceptions.InvalidValue( + "use_iam_endpoint should be True for non-default universe domain" + ) cred._use_iam_endpoint = use_iam_endpoint return cred diff --git a/tests/oauth2/test_service_account.py b/tests/oauth2/test_service_account.py index 11b41c47f..c48635d4d 100644 --- a/tests/oauth2/test_service_account.py +++ b/tests/oauth2/test_service_account.py @@ -212,6 +212,15 @@ def test__with_always_use_jwt_access(self): new_credentials = credentials.with_always_use_jwt_access(True) assert new_credentials._always_use_jwt_access + def test__with_always_use_jwt_access_non_default_universe_domain(self): + credentials = self.make_credentials(universe_domain=FAKE_UNIVERSE_DOMAIN) + with pytest.raises(exceptions.InvalidValue) as excinfo: + credentials.with_always_use_jwt_access(False) + + assert excinfo.match( + "always_use_jwt_access should be True for non-default universe domain" + ) + def test__make_authorization_grant_assertion(self): credentials = self.make_credentials() token = credentials._make_authorization_grant_assertion() @@ -632,6 +641,15 @@ def test__with_use_iam_endpoint(self): new_credentials = credentials._with_use_iam_endpoint(True) assert new_credentials._use_iam_endpoint + def test__with_use_iam_endpoint_non_default_universe_domain(self): + credentials = self.make_credentials(universe_domain=FAKE_UNIVERSE_DOMAIN) + with pytest.raises(exceptions.InvalidValue) as excinfo: + credentials._with_use_iam_endpoint(False) + + assert excinfo.match( + "use_iam_endpoint should be True for non-default universe domain" + ) + def test_with_quota_project(self): credentials = self.make_credentials() new_credentials = credentials.with_quota_project("project-foo") From b8c9d647464a911ef9eee2fe6ae6b3a4768920d9 Mon Sep 17 00:00:00 2001 From: Sijun Liu Date: Tue, 9 May 2023 21:49:18 -0700 Subject: [PATCH 5/5] update token --- system_tests/secrets.tar.enc | Bin 10324 -> 10324 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/system_tests/secrets.tar.enc b/system_tests/secrets.tar.enc index f253a74cb4cc9fa00854cc2b9496d0ee1a2763a9..8a3cf40107a31dd6d46914ddac7d4194da68c2c6 100644 GIT binary patch literal 10324 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