diff --git a/CHANGELOG.md b/CHANGELOG.md index c7f8e51ce..cb6a41358 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ [1]: https://pypi.org/project/google-auth/#history +## [2.38.0](https://github.com/googleapis/google-auth-library-python/compare/v2.37.0...v2.38.0) (2025-01-23) + + +### Features + +* Adding domain-wide delegation flow in impersonated credential ([#1624](https://github.com/googleapis/google-auth-library-python/issues/1624)) ([34ee3fe](https://github.com/googleapis/google-auth-library-python/commit/34ee3fef8cba6a1bbaa46fa16b43af0d89b60b0f)) + + +### Documentation + +* Add warnings regarding consuming externally sourced credentials ([d049370](https://github.com/googleapis/google-auth-library-python/commit/d049370d266b50db0e09d7b292dbf33052b27853)) + ## [2.37.0](https://github.com/googleapis/google-auth-library-python/compare/v2.36.1...v2.37.0) (2024-12-11) diff --git a/docs/user-guide.rst b/docs/user-guide.rst index 3545a8a31..04dffaf89 100644 --- a/docs/user-guide.rst +++ b/docs/user-guide.rst @@ -29,6 +29,17 @@ that supports OpenID Connect (OIDC). Obtaining credentials --------------------- +.. warning:: + Important: If you accept a credential configuration (credential JSON/File/Stream) + from an external source for authentication to Google Cloud Platform, you must + validate it before providing it to any Google API or client library. Providing an + unvalidated credential configuration to Google APIs or libraries can compromise + the security of your systems and data. For more information, refer to + `Validate credential configurations from external sources`_. + +.. _Validate credential configurations from external sources: + https://cloud.google.com/docs/authentication/external/externally-sourced-credentials + .. _application-default: Application default credentials diff --git a/google/auth/_default.py b/google/auth/_default.py index cdc8b7a64..1234fb25d 100644 --- a/google/auth/_default.py +++ b/google/auth/_default.py @@ -85,6 +85,17 @@ def load_credentials_from_file( user credentials, external account credentials, or impersonated service account credentials. + .. warning:: + Important: If you accept a credential configuration (credential JSON/File/Stream) + from an external source for authentication to Google Cloud Platform, you must + validate it before providing it to any Google API or client library. Providing an + unvalidated credential configuration to Google APIs or libraries can compromise + the security of your systems and data. For more information, refer to + `Validate credential configurations from external sources`_. + + .. _Validate credential configurations from external sources: + https://cloud.google.com/docs/authentication/external/externally-sourced-credentials + Args: filename (str): The full path to the credentials file. scopes (Optional[Sequence[str]]): The list of scopes for the credentials. If @@ -137,6 +148,17 @@ def load_credentials_from_dict( user credentials, external account credentials, or impersonated service account credentials. + .. warning:: + Important: If you accept a credential configuration (credential JSON/File/Stream) + from an external source for authentication to Google Cloud Platform, you must + validate it before providing it to any Google API or client library. Providing an + unvalidated credential configuration to Google APIs or libraries can compromise + the security of your systems and data. For more information, refer to + `Validate credential configurations from external sources`_. + + .. _Validate credential configurations from external sources: + https://cloud.google.com/docs/authentication/external/externally-sourced-credentials + Args: info (Dict[str, Any]): A dict object containing the credentials scopes (Optional[Sequence[str]]): The list of scopes for the credentials. If diff --git a/google/auth/compute_engine/_metadata.py b/google/auth/compute_engine/_metadata.py index 8d692972f..06f99de0e 100644 --- a/google/auth/compute_engine/_metadata.py +++ b/google/auth/compute_engine/_metadata.py @@ -201,7 +201,7 @@ def get( url = _helpers.update_query(base_url, query_params) backoff = ExponentialBackoff(total_attempts=retry_count) - + failure_reason = None for attempt in backoff: try: response = request(url=url, method="GET", headers=headers_to_use) @@ -213,6 +213,11 @@ def get( retry_count, response.status, ) + failure_reason = ( + response.data.decode("utf-8") + if hasattr(response.data, "decode") + else response.data + ) continue else: break @@ -225,10 +230,13 @@ def get( retry_count, e, ) + failure_reason = e else: raise exceptions.TransportError( "Failed to retrieve {} from the Google Compute Engine " - "metadata service. Compute Engine Metadata server unavailable".format(url) + "metadata service. Compute Engine Metadata server unavailable due to {}".format( + url, failure_reason + ) ) content = _helpers.from_bytes(response.data) diff --git a/google/auth/iam.py b/google/auth/iam.py index dcf0dbf9d..1e4cdffec 100644 --- a/google/auth/iam.py +++ b/google/auth/iam.py @@ -48,6 +48,11 @@ + "/serviceAccounts/{}:signBlob" ) +_IAM_SIGNJWT_ENDPOINT = ( + "https://iamcredentials.googleapis.com/v1/projects/-" + + "/serviceAccounts/{}:signJwt" +) + _IAM_IDTOKEN_ENDPOINT = ( "https://iamcredentials.googleapis.com/v1/" + "projects/-/serviceAccounts/{}:generateIdToken" diff --git a/google/auth/impersonated_credentials.py b/google/auth/impersonated_credentials.py index d51c8ef1e..ed7e3f00b 100644 --- a/google/auth/impersonated_credentials.py +++ b/google/auth/impersonated_credentials.py @@ -38,12 +38,15 @@ from google.auth import iam from google.auth import jwt from google.auth import metrics +from google.oauth2 import _client _REFRESH_ERROR = "Unable to acquire impersonated credentials" _DEFAULT_TOKEN_LIFETIME_SECS = 3600 # 1 hour in seconds +_GOOGLE_OAUTH2_TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token" + def _make_iam_token_request( request, @@ -177,6 +180,7 @@ def __init__( target_principal, target_scopes, delegates=None, + subject=None, lifetime=_DEFAULT_TOKEN_LIFETIME_SECS, quota_project_id=None, iam_endpoint_override=None, @@ -204,9 +208,12 @@ def __init__( quota_project_id (Optional[str]): The project ID used for quota and billing. This project may be different from the project used to create the credentials. - iam_endpoint_override (Optiona[str]): The full IAM endpoint override + iam_endpoint_override (Optional[str]): The full IAM endpoint override with the target_principal embedded. This is useful when supporting impersonation with regional endpoints. + subject (Optional[str]): sub field of a JWT. This field should only be set + if you wish to impersonate as a user. This feature is useful when + using domain wide delegation. """ super(Credentials, self).__init__() @@ -231,6 +238,7 @@ def __init__( self._target_principal = target_principal self._target_scopes = target_scopes self._delegates = delegates + self._subject = subject self._lifetime = lifetime or _DEFAULT_TOKEN_LIFETIME_SECS self.token = None self.expiry = _helpers.utcnow() @@ -275,6 +283,39 @@ def _update_token(self, request): # Apply the source credentials authentication info. self._source_credentials.apply(headers) + # If a subject is specified a domain-wide delegation auth-flow is initiated + # to impersonate as the provided subject (user). + if self._subject: + if self.universe_domain != credentials.DEFAULT_UNIVERSE_DOMAIN: + raise exceptions.GoogleAuthError( + "Domain-wide delegation is not supported in universes other " + + "than googleapis.com" + ) + + now = _helpers.utcnow() + payload = { + "iss": self._target_principal, + "scope": _helpers.scopes_to_string(self._target_scopes or ()), + "sub": self._subject, + "aud": _GOOGLE_OAUTH2_TOKEN_ENDPOINT, + "iat": _helpers.datetime_to_secs(now), + "exp": _helpers.datetime_to_secs(now) + _DEFAULT_TOKEN_LIFETIME_SECS, + } + + assertion = _sign_jwt_request( + request=request, + principal=self._target_principal, + headers=headers, + payload=payload, + delegates=self._delegates, + ) + + self.token, self.expiry, _ = _client.jwt_grant( + request, _GOOGLE_OAUTH2_TOKEN_ENDPOINT, assertion + ) + + return + self.token, self.expiry = _make_iam_token_request( request=request, principal=self._target_principal, @@ -478,3 +519,61 @@ def refresh(self, request): self.expiry = datetime.utcfromtimestamp( jwt.decode(id_token, verify=False)["exp"] ) + + +def _sign_jwt_request(request, principal, headers, payload, delegates=[]): + """Makes a request to the Google Cloud IAM service to sign a JWT using a + service account's system-managed private key. + Args: + request (Request): The Request object to use. + principal (str): The principal to request an access token for. + headers (Mapping[str, str]): Map of headers to transmit. + payload (Mapping[str, str]): The JWT payload to sign. Must be a + serialized JSON object that contains a JWT Claims Set. + delegates (Sequence[str]): The chained list of delegates required + to grant the final access_token. If set, the sequence of + identities must have "Service Account Token Creator" capability + granted to the prceeding identity. For example, if set to + [serviceAccountB, serviceAccountC], the source_credential + must have the Token Creator role on serviceAccountB. + serviceAccountB must have the Token Creator on + serviceAccountC. + Finally, C must have Token Creator on target_principal. + If left unset, source_credential must have that role on + target_principal. + + Raises: + google.auth.exceptions.TransportError: Raised if there is an underlying + HTTP connection error + google.auth.exceptions.RefreshError: Raised if the impersonated + credentials are not available. Common reasons are + `iamcredentials.googleapis.com` is not enabled or the + `Service Account Token Creator` is not assigned + """ + iam_endpoint = iam._IAM_SIGNJWT_ENDPOINT.format(principal) + + body = {"delegates": delegates, "payload": json.dumps(payload)} + body = json.dumps(body).encode("utf-8") + + response = request(url=iam_endpoint, method="POST", headers=headers, body=body) + + # support both string and bytes type response.data + response_body = ( + response.data.decode("utf-8") + if hasattr(response.data, "decode") + else response.data + ) + + if response.status != http_client.OK: + raise exceptions.RefreshError(_REFRESH_ERROR, response_body) + + try: + jwt_response = json.loads(response_body) + signed_jwt = jwt_response["signedJwt"] + return signed_jwt + + except (KeyError, ValueError) as caught_exc: + new_exc = exceptions.RefreshError( + "{}: No signed JWT in response.".format(_REFRESH_ERROR), response_body + ) + raise new_exc from caught_exc diff --git a/google/auth/version.py b/google/auth/version.py index 06ec7e7fb..41a80e6c6 100644 --- a/google/auth/version.py +++ b/google/auth/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "2.37.0" +__version__ = "2.38.0" diff --git a/system_tests/secrets.tar.enc b/system_tests/secrets.tar.enc index 8c0501b3c..3f239d76b 100644 Binary files a/system_tests/secrets.tar.enc and b/system_tests/secrets.tar.enc differ diff --git a/tests/compute_engine/test__metadata.py b/tests/compute_engine/test__metadata.py index c5f80d897..7c028eb62 100644 --- a/tests/compute_engine/test__metadata.py +++ b/tests/compute_engine/test__metadata.py @@ -344,12 +344,32 @@ def test_get_return_none_for_not_found_error(): @mock.patch("time.sleep", return_value=None) def test_get_failure_connection_failed(mock_sleep): request = make_request("") - request.side_effect = exceptions.TransportError() + request.side_effect = exceptions.TransportError("failure message") with pytest.raises(exceptions.TransportError) as excinfo: _metadata.get(request, PATH) - assert excinfo.match(r"Compute Engine Metadata server unavailable") + assert excinfo.match( + r"Compute Engine Metadata server unavailable due to failure message" + ) + + request.assert_called_with( + method="GET", + url=_metadata._METADATA_ROOT + PATH, + headers=_metadata._METADATA_HEADERS, + ) + assert request.call_count == 5 + + +def test_get_too_many_requests_retryable_error_failure(): + request = make_request("too many requests", status=http_client.TOO_MANY_REQUESTS) + + with pytest.raises(exceptions.TransportError) as excinfo: + _metadata.get(request, PATH) + + assert excinfo.match( + r"Compute Engine Metadata server unavailable due to too many requests" + ) request.assert_called_with( method="GET", diff --git a/tests/test_impersonated_credentials.py b/tests/test_impersonated_credentials.py index 0fe6e2329..8f6b22670 100644 --- a/tests/test_impersonated_credentials.py +++ b/tests/test_impersonated_credentials.py @@ -71,6 +71,17 @@ def mock_donor_credentials(): yield grant +@pytest.fixture +def mock_dwd_credentials(): + with mock.patch("google.oauth2._client.jwt_grant", autospec=True) as grant: + grant.return_value = ( + "1/fFAGRNJasdfz70BzhT3Zg", + _helpers.utcnow() + datetime.timedelta(seconds=500), + {}, + ) + yield grant + + class MockResponse: def __init__(self, json_data, status_code): self.json_data = json_data @@ -123,6 +134,7 @@ def make_credentials( source_credentials=SOURCE_CREDENTIALS, lifetime=LIFETIME, target_principal=TARGET_PRINCIPAL, + subject=None, iam_endpoint_override=None, ): @@ -132,6 +144,7 @@ def make_credentials( target_scopes=self.TARGET_SCOPES, delegates=self.DELEGATES, lifetime=lifetime, + subject=subject, iam_endpoint_override=iam_endpoint_override, ) @@ -238,6 +251,28 @@ def test_refresh_success(self, use_data_bytes, mock_donor_credentials): == ACCESS_TOKEN_REQUEST_METRICS_HEADER_VALUE ) + @pytest.mark.parametrize("use_data_bytes", [True, False]) + def test_refresh_with_subject_success(self, use_data_bytes, mock_dwd_credentials): + credentials = self.make_credentials(subject="test@email.com", lifetime=None) + + response_body = {"signedJwt": "example_signed_jwt"} + + request = self.make_request( + data=json.dumps(response_body), + status=http_client.OK, + use_data_bytes=use_data_bytes, + ) + + with mock.patch( + "google.auth.metrics.token_request_access_token_impersonate", + return_value=ACCESS_TOKEN_REQUEST_METRICS_HEADER_VALUE, + ): + credentials.refresh(request) + + assert credentials.valid + assert not credentials.expired + assert credentials.token == "1/fFAGRNJasdfz70BzhT3Zg" + @pytest.mark.parametrize("use_data_bytes", [True, False]) def test_refresh_success_nonGdu(self, use_data_bytes, mock_donor_credentials): source_credentials = service_account.Credentials( @@ -418,6 +453,33 @@ def test_refresh_failure_http_error(self, mock_donor_credentials): assert not credentials.valid assert credentials.expired + def test_refresh_failure_subject_with_nondefault_domain( + self, mock_donor_credentials + ): + source_credentials = service_account.Credentials( + SIGNER, "some@email.com", TOKEN_URI, universe_domain="foo.bar" + ) + credentials = self.make_credentials( + source_credentials=source_credentials, subject="test@email.com" + ) + + expire_time = (_helpers.utcnow().replace(microsecond=0)).isoformat("T") + "Z" + response_body = {"accessToken": "token", "expireTime": expire_time} + request = self.make_request( + data=json.dumps(response_body), status=http_client.OK + ) + + with pytest.raises(exceptions.GoogleAuthError) as excinfo: + credentials.refresh(request) + + assert excinfo.match( + "Domain-wide delegation is not supported in universes other " + + "than googleapis.com" + ) + + assert not credentials.valid + assert credentials.expired + def test_expired(self): credentials = self.make_credentials(lifetime=None) assert credentials.expired @@ -810,3 +872,61 @@ def test_id_token_with_quota_project( id_creds.refresh(request) assert id_creds.quota_project_id == "project-foo" + + def test_sign_jwt_request_success(self): + principal = "foo@example.com" + expected_signed_jwt = "correct_signed_jwt" + + response_body = {"keyId": "1", "signedJwt": expected_signed_jwt} + request = self.make_request( + data=json.dumps(response_body), status=http_client.OK + ) + + signed_jwt = impersonated_credentials._sign_jwt_request( + request=request, principal=principal, headers={}, payload={} + ) + + assert signed_jwt == expected_signed_jwt + request.assert_called_once_with( + url="https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/foo@example.com:signJwt", + method="POST", + headers={}, + body=json.dumps({"delegates": [], "payload": json.dumps({})}).encode( + "utf-8" + ), + ) + + def test_sign_jwt_request_http_error(self): + principal = "foo@example.com" + + request = self.make_request( + data="error_message", status=http_client.BAD_REQUEST + ) + + with pytest.raises(exceptions.RefreshError) as excinfo: + _ = impersonated_credentials._sign_jwt_request( + request=request, principal=principal, headers={}, payload={} + ) + + assert excinfo.match(impersonated_credentials._REFRESH_ERROR) + + assert excinfo.value.args[0] == "Unable to acquire impersonated credentials" + assert excinfo.value.args[1] == "error_message" + + def test_sign_jwt_request_invalid_response_error(self): + principal = "foo@example.com" + + request = self.make_request(data="invalid_data", status=http_client.OK) + + with pytest.raises(exceptions.RefreshError) as excinfo: + _ = impersonated_credentials._sign_jwt_request( + request=request, principal=principal, headers={}, payload={} + ) + + assert excinfo.match(impersonated_credentials._REFRESH_ERROR) + + assert ( + excinfo.value.args[0] + == "Unable to acquire impersonated credentials: No signed JWT in response." + ) + assert excinfo.value.args[1] == "invalid_data"
Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.
Alternative Proxies: