Skip to content

Commit 1bee82b

Browse files
authored
Merge branch 'main' into owl-bot-update-lock-04c35dc5f49f0f503a306397d6d043685f8d2bb822ab515818c4208d7fb2db3a
2 parents f913968 + ca82184 commit 1bee82b

File tree

10 files changed

+303
-6
lines changed

10 files changed

+303
-6
lines changed

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,18 @@
44

55
[1]: https://pypi.org/project/google-auth/#history
66

7+
## [2.38.0](https://github.com/googleapis/google-auth-library-python/compare/v2.37.0...v2.38.0) (2025-01-23)
8+
9+
10+
### Features
11+
12+
* 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))
13+
14+
15+
### Documentation
16+
17+
* Add warnings regarding consuming externally sourced credentials ([d049370](https://github.com/googleapis/google-auth-library-python/commit/d049370d266b50db0e09d7b292dbf33052b27853))
18+
719
## [2.37.0](https://github.com/googleapis/google-auth-library-python/compare/v2.36.1...v2.37.0) (2024-12-11)
820

921

docs/user-guide.rst

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,17 @@ that supports OpenID Connect (OIDC).
2929
Obtaining credentials
3030
---------------------
3131

32+
.. warning::
33+
Important: If you accept a credential configuration (credential JSON/File/Stream)
34+
from an external source for authentication to Google Cloud Platform, you must
35+
validate it before providing it to any Google API or client library. Providing an
36+
unvalidated credential configuration to Google APIs or libraries can compromise
37+
the security of your systems and data. For more information, refer to
38+
`Validate credential configurations from external sources`_.
39+
40+
.. _Validate credential configurations from external sources:
41+
https://cloud.google.com/docs/authentication/external/externally-sourced-credentials
42+
3243
.. _application-default:
3344

3445
Application default credentials

google/auth/_default.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,17 @@ def load_credentials_from_file(
8585
user credentials, external account credentials, or impersonated service
8686
account credentials.
8787
88+
.. warning::
89+
Important: If you accept a credential configuration (credential JSON/File/Stream)
90+
from an external source for authentication to Google Cloud Platform, you must
91+
validate it before providing it to any Google API or client library. Providing an
92+
unvalidated credential configuration to Google APIs or libraries can compromise
93+
the security of your systems and data. For more information, refer to
94+
`Validate credential configurations from external sources`_.
95+
96+
.. _Validate credential configurations from external sources:
97+
https://cloud.google.com/docs/authentication/external/externally-sourced-credentials
98+
8899
Args:
89100
filename (str): The full path to the credentials file.
90101
scopes (Optional[Sequence[str]]): The list of scopes for the credentials. If
@@ -137,6 +148,17 @@ def load_credentials_from_dict(
137148
user credentials, external account credentials, or impersonated service
138149
account credentials.
139150
151+
.. warning::
152+
Important: If you accept a credential configuration (credential JSON/File/Stream)
153+
from an external source for authentication to Google Cloud Platform, you must
154+
validate it before providing it to any Google API or client library. Providing an
155+
unvalidated credential configuration to Google APIs or libraries can compromise
156+
the security of your systems and data. For more information, refer to
157+
`Validate credential configurations from external sources`_.
158+
159+
.. _Validate credential configurations from external sources:
160+
https://cloud.google.com/docs/authentication/external/externally-sourced-credentials
161+
140162
Args:
141163
info (Dict[str, Any]): A dict object containing the credentials
142164
scopes (Optional[Sequence[str]]): The list of scopes for the credentials. If

google/auth/compute_engine/_metadata.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,7 @@ def get(
201201
url = _helpers.update_query(base_url, query_params)
202202

203203
backoff = ExponentialBackoff(total_attempts=retry_count)
204-
204+
failure_reason = None
205205
for attempt in backoff:
206206
try:
207207
response = request(url=url, method="GET", headers=headers_to_use)
@@ -213,6 +213,11 @@ def get(
213213
retry_count,
214214
response.status,
215215
)
216+
failure_reason = (
217+
response.data.decode("utf-8")
218+
if hasattr(response.data, "decode")
219+
else response.data
220+
)
216221
continue
217222
else:
218223
break
@@ -225,10 +230,13 @@ def get(
225230
retry_count,
226231
e,
227232
)
233+
failure_reason = e
228234
else:
229235
raise exceptions.TransportError(
230236
"Failed to retrieve {} from the Google Compute Engine "
231-
"metadata service. Compute Engine Metadata server unavailable".format(url)
237+
"metadata service. Compute Engine Metadata server unavailable due to {}".format(
238+
url, failure_reason
239+
)
232240
)
233241

234242
content = _helpers.from_bytes(response.data)

google/auth/iam.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,11 @@
4848
+ "/serviceAccounts/{}:signBlob"
4949
)
5050

51+
_IAM_SIGNJWT_ENDPOINT = (
52+
"https://iamcredentials.googleapis.com/v1/projects/-"
53+
+ "/serviceAccounts/{}:signJwt"
54+
)
55+
5156
_IAM_IDTOKEN_ENDPOINT = (
5257
"https://iamcredentials.googleapis.com/v1/"
5358
+ "projects/-/serviceAccounts/{}:generateIdToken"

google/auth/impersonated_credentials.py

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,15 @@
3838
from google.auth import iam
3939
from google.auth import jwt
4040
from google.auth import metrics
41+
from google.oauth2 import _client
4142

4243

4344
_REFRESH_ERROR = "Unable to acquire impersonated credentials"
4445

4546
_DEFAULT_TOKEN_LIFETIME_SECS = 3600 # 1 hour in seconds
4647

48+
_GOOGLE_OAUTH2_TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token"
49+
4750

4851
def _make_iam_token_request(
4952
request,
@@ -177,6 +180,7 @@ def __init__(
177180
target_principal,
178181
target_scopes,
179182
delegates=None,
183+
subject=None,
180184
lifetime=_DEFAULT_TOKEN_LIFETIME_SECS,
181185
quota_project_id=None,
182186
iam_endpoint_override=None,
@@ -204,9 +208,12 @@ def __init__(
204208
quota_project_id (Optional[str]): The project ID used for quota and billing.
205209
This project may be different from the project used to
206210
create the credentials.
207-
iam_endpoint_override (Optiona[str]): The full IAM endpoint override
211+
iam_endpoint_override (Optional[str]): The full IAM endpoint override
208212
with the target_principal embedded. This is useful when supporting
209213
impersonation with regional endpoints.
214+
subject (Optional[str]): sub field of a JWT. This field should only be set
215+
if you wish to impersonate as a user. This feature is useful when
216+
using domain wide delegation.
210217
"""
211218

212219
super(Credentials, self).__init__()
@@ -231,6 +238,7 @@ def __init__(
231238
self._target_principal = target_principal
232239
self._target_scopes = target_scopes
233240
self._delegates = delegates
241+
self._subject = subject
234242
self._lifetime = lifetime or _DEFAULT_TOKEN_LIFETIME_SECS
235243
self.token = None
236244
self.expiry = _helpers.utcnow()
@@ -275,6 +283,39 @@ def _update_token(self, request):
275283
# Apply the source credentials authentication info.
276284
self._source_credentials.apply(headers)
277285

286+
# If a subject is specified a domain-wide delegation auth-flow is initiated
287+
# to impersonate as the provided subject (user).
288+
if self._subject:
289+
if self.universe_domain != credentials.DEFAULT_UNIVERSE_DOMAIN:
290+
raise exceptions.GoogleAuthError(
291+
"Domain-wide delegation is not supported in universes other "
292+
+ "than googleapis.com"
293+
)
294+
295+
now = _helpers.utcnow()
296+
payload = {
297+
"iss": self._target_principal,
298+
"scope": _helpers.scopes_to_string(self._target_scopes or ()),
299+
"sub": self._subject,
300+
"aud": _GOOGLE_OAUTH2_TOKEN_ENDPOINT,
301+
"iat": _helpers.datetime_to_secs(now),
302+
"exp": _helpers.datetime_to_secs(now) + _DEFAULT_TOKEN_LIFETIME_SECS,
303+
}
304+
305+
assertion = _sign_jwt_request(
306+
request=request,
307+
principal=self._target_principal,
308+
headers=headers,
309+
payload=payload,
310+
delegates=self._delegates,
311+
)
312+
313+
self.token, self.expiry, _ = _client.jwt_grant(
314+
request, _GOOGLE_OAUTH2_TOKEN_ENDPOINT, assertion
315+
)
316+
317+
return
318+
278319
self.token, self.expiry = _make_iam_token_request(
279320
request=request,
280321
principal=self._target_principal,
@@ -478,3 +519,61 @@ def refresh(self, request):
478519
self.expiry = datetime.utcfromtimestamp(
479520
jwt.decode(id_token, verify=False)["exp"]
480521
)
522+
523+
524+
def _sign_jwt_request(request, principal, headers, payload, delegates=[]):
525+
"""Makes a request to the Google Cloud IAM service to sign a JWT using a
526+
service account's system-managed private key.
527+
Args:
528+
request (Request): The Request object to use.
529+
principal (str): The principal to request an access token for.
530+
headers (Mapping[str, str]): Map of headers to transmit.
531+
payload (Mapping[str, str]): The JWT payload to sign. Must be a
532+
serialized JSON object that contains a JWT Claims Set.
533+
delegates (Sequence[str]): The chained list of delegates required
534+
to grant the final access_token. If set, the sequence of
535+
identities must have "Service Account Token Creator" capability
536+
granted to the prceeding identity. For example, if set to
537+
[serviceAccountB, serviceAccountC], the source_credential
538+
must have the Token Creator role on serviceAccountB.
539+
serviceAccountB must have the Token Creator on
540+
serviceAccountC.
541+
Finally, C must have Token Creator on target_principal.
542+
If left unset, source_credential must have that role on
543+
target_principal.
544+
545+
Raises:
546+
google.auth.exceptions.TransportError: Raised if there is an underlying
547+
HTTP connection error
548+
google.auth.exceptions.RefreshError: Raised if the impersonated
549+
credentials are not available. Common reasons are
550+
`iamcredentials.googleapis.com` is not enabled or the
551+
`Service Account Token Creator` is not assigned
552+
"""
553+
iam_endpoint = iam._IAM_SIGNJWT_ENDPOINT.format(principal)
554+
555+
body = {"delegates": delegates, "payload": json.dumps(payload)}
556+
body = json.dumps(body).encode("utf-8")
557+
558+
response = request(url=iam_endpoint, method="POST", headers=headers, body=body)
559+
560+
# support both string and bytes type response.data
561+
response_body = (
562+
response.data.decode("utf-8")
563+
if hasattr(response.data, "decode")
564+
else response.data
565+
)
566+
567+
if response.status != http_client.OK:
568+
raise exceptions.RefreshError(_REFRESH_ERROR, response_body)
569+
570+
try:
571+
jwt_response = json.loads(response_body)
572+
signed_jwt = jwt_response["signedJwt"]
573+
return signed_jwt
574+
575+
except (KeyError, ValueError) as caught_exc:
576+
new_exc = exceptions.RefreshError(
577+
"{}: No signed JWT in response.".format(_REFRESH_ERROR), response_body
578+
)
579+
raise new_exc from caught_exc

google/auth/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,4 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15-
__version__ = "2.37.0"
15+
__version__ = "2.38.0"

system_tests/secrets.tar.enc

0 Bytes
Binary file not shown.

tests/compute_engine/test__metadata.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -344,12 +344,32 @@ def test_get_return_none_for_not_found_error():
344344
@mock.patch("time.sleep", return_value=None)
345345
def test_get_failure_connection_failed(mock_sleep):
346346
request = make_request("")
347-
request.side_effect = exceptions.TransportError()
347+
request.side_effect = exceptions.TransportError("failure message")
348348

349349
with pytest.raises(exceptions.TransportError) as excinfo:
350350
_metadata.get(request, PATH)
351351

352-
assert excinfo.match(r"Compute Engine Metadata server unavailable")
352+
assert excinfo.match(
353+
r"Compute Engine Metadata server unavailable due to failure message"
354+
)
355+
356+
request.assert_called_with(
357+
method="GET",
358+
url=_metadata._METADATA_ROOT + PATH,
359+
headers=_metadata._METADATA_HEADERS,
360+
)
361+
assert request.call_count == 5
362+
363+
364+
def test_get_too_many_requests_retryable_error_failure():
365+
request = make_request("too many requests", status=http_client.TOO_MANY_REQUESTS)
366+
367+
with pytest.raises(exceptions.TransportError) as excinfo:
368+
_metadata.get(request, PATH)
369+
370+
assert excinfo.match(
371+
r"Compute Engine Metadata server unavailable due to too many requests"
372+
)
353373

354374
request.assert_called_with(
355375
method="GET",

0 commit comments

Comments
 (0)