1# Copyright 2016 Google LLC
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
15"""OAuth 2.0 client.
16
17This is a client for interacting with an OAuth 2.0 authorization server's
18token endpoint.
19
20For more information about the token endpoint, see
21`Section 3.1 of rfc6749`_
22
23.. _Section 3.1 of rfc6749: https://tools.ietf.org/html/rfc6749#section-3.2
24"""
25
26import datetime
27import http.client as http_client
28import json
29import logging
30import urllib
31
32from google.auth import (
33 _exponential_backoff,
34 _helpers,
35 credentials,
36 exceptions,
37 jwt,
38 metrics,
39 transport,
40)
41
42_LOGGER = logging.getLogger(__name__)
43
44_URLENCODED_CONTENT_TYPE = "application/x-www-form-urlencoded"
45_JSON_CONTENT_TYPE = "application/json"
46_JWT_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:jwt-bearer"
47_REFRESH_GRANT_TYPE = "refresh_token"
48_BLOCKING_REGIONAL_ACCESS_BOUNDARY_LOOKUP_TIMEOUT = 3
49
50
51def _handle_error_response(response_data, retryable_error):
52 """Translates an error response into an exception.
53
54 Args:
55 response_data (Mapping | str): The decoded response data.
56 retryable_error Optional[bool]: A boolean indicating if an error is retryable.
57 Defaults to False.
58
59 Raises:
60 google.auth.exceptions.RefreshError: The errors contained in response_data.
61 """
62
63 retryable_error = retryable_error if retryable_error else False
64
65 if isinstance(response_data, str):
66 raise exceptions.RefreshError(response_data, retryable=retryable_error)
67 try:
68 error_details = "{}: {}".format(
69 response_data["error"], response_data.get("error_description")
70 )
71 # If no details could be extracted, use the response data.
72 except (KeyError, ValueError):
73 error_details = json.dumps(response_data)
74
75 raise exceptions.RefreshError(
76 error_details, response_data, retryable=retryable_error
77 )
78
79
80def _can_retry(status_code, response_data):
81 """Checks if a request can be retried by inspecting the status code
82 and response body of the request.
83
84 Args:
85 status_code (int): The response status code.
86 response_data (Mapping | str): The decoded response data.
87
88 Returns:
89 bool: True if the response is retryable. False otherwise.
90 """
91 if status_code in transport.DEFAULT_RETRYABLE_STATUS_CODES:
92 return True
93
94 try:
95 # For a failed response, response_body could be a string
96 error_desc = response_data.get("error_description") or ""
97 error_code = response_data.get("error") or ""
98
99 if not isinstance(error_code, str) or not isinstance(error_desc, str):
100 return False
101
102 # Per Oauth 2.0 RFC https://www.rfc-editor.org/rfc/rfc6749.html#section-4.1.2.1
103 # This is needed because a redirect will not return a 500 status code.
104 retryable_error_descriptions = {
105 "internal_failure",
106 "server_error",
107 "temporarily_unavailable",
108 }
109
110 if any(e in retryable_error_descriptions for e in (error_code, error_desc)):
111 return True
112
113 except AttributeError:
114 pass
115
116 return False
117
118
119def _parse_expiry(response_data):
120 """Parses the expiry field from a response into a datetime.
121
122 Args:
123 response_data (Mapping): The JSON-parsed response data.
124
125 Returns:
126 Optional[datetime]: The expiration or ``None`` if no expiration was
127 specified.
128 """
129 expires_in = response_data.get("expires_in", None)
130
131 if expires_in is not None:
132 # Some services do not respect the OAUTH2.0 RFC and send expires_in as a
133 # JSON String.
134 if isinstance(expires_in, str):
135 expires_in = int(expires_in)
136
137 return _helpers.utcnow() + datetime.timedelta(seconds=expires_in)
138 else:
139 return None
140
141
142def _token_endpoint_request_no_throw(
143 request,
144 token_uri,
145 body,
146 access_token=None,
147 use_json=False,
148 can_retry=True,
149 headers=None,
150 **kwargs,
151):
152 """Makes a request to the OAuth 2.0 authorization server's token endpoint.
153 This function doesn't throw on response errors.
154
155 Args:
156 request (google.auth.transport.Request): A callable used to make
157 HTTP requests.
158 token_uri (str): The OAuth 2.0 authorizations server's token endpoint
159 URI.
160 body (Mapping[str, str]): The parameters to send in the request body.
161 access_token (Optional(str)): The access token needed to make the request.
162 use_json (Optional(bool)): Use urlencoded format or json format for the
163 content type. The default value is False.
164 can_retry (bool): Enable or disable request retry behavior.
165 headers (Optional[Mapping[str, str]]): The headers for the request.
166 kwargs: Additional arguments passed on to the request method. The
167 kwargs will be passed to `requests.request` method, see:
168 https://docs.python-requests.org/en/latest/api/#requests.request.
169 For example, you can use `cert=("cert_pem_path", "key_pem_path")`
170 to set up client side SSL certificate, and use
171 `verify="ca_bundle_path"` to set up the CA certificates for sever
172 side SSL certificate verification.
173
174 Returns:
175 Tuple(bool, Mapping[str, str], Optional[bool]): A boolean indicating
176 if the request is successful, a mapping for the JSON-decoded response
177 data and in the case of an error a boolean indicating if the error
178 is retryable.
179 """
180 if use_json:
181 headers_to_use = {"Content-Type": _JSON_CONTENT_TYPE}
182 body = json.dumps(body).encode("utf-8")
183 else:
184 headers_to_use = {"Content-Type": _URLENCODED_CONTENT_TYPE}
185 body = urllib.parse.urlencode(body).encode("utf-8")
186
187 if access_token:
188 headers_to_use["Authorization"] = "Bearer {}".format(access_token)
189
190 if headers:
191 headers_to_use.update(headers)
192
193 response_data = {}
194 retryable_error = False
195
196 retries = _exponential_backoff.ExponentialBackoff()
197 for _ in retries:
198 response = request(
199 method="POST", url=token_uri, headers=headers_to_use, body=body, **kwargs
200 )
201 response_body = (
202 response.data.decode("utf-8")
203 if hasattr(response.data, "decode")
204 else response.data
205 )
206
207 try:
208 # response_body should be a JSON
209 response_data = json.loads(response_body)
210 except ValueError:
211 response_data = response_body
212
213 if response.status == http_client.OK:
214 return True, response_data, None
215
216 retryable_error = _can_retry(
217 status_code=response.status, response_data=response_data
218 )
219
220 if not can_retry or not retryable_error:
221 return False, response_data, retryable_error
222
223 return False, response_data, retryable_error
224
225
226def _token_endpoint_request(
227 request,
228 token_uri,
229 body,
230 access_token=None,
231 use_json=False,
232 can_retry=True,
233 headers=None,
234 **kwargs,
235):
236 """Makes a request to the OAuth 2.0 authorization server's token endpoint.
237
238 Args:
239 request (google.auth.transport.Request): A callable used to make
240 HTTP requests.
241 token_uri (str): The OAuth 2.0 authorizations server's token endpoint
242 URI.
243 body (Mapping[str, str]): The parameters to send in the request body.
244 access_token (Optional(str)): The access token needed to make the request.
245 use_json (Optional(bool)): Use urlencoded format or json format for the
246 content type. The default value is False.
247 can_retry (bool): Enable or disable request retry behavior.
248 headers (Optional[Mapping[str, str]]): The headers for the request.
249 kwargs: Additional arguments passed on to the request method. The
250 kwargs will be passed to `requests.request` method, see:
251 https://docs.python-requests.org/en/latest/api/#requests.request.
252 For example, you can use `cert=("cert_pem_path", "key_pem_path")`
253 to set up client side SSL certificate, and use
254 `verify="ca_bundle_path"` to set up the CA certificates for sever
255 side SSL certificate verification.
256
257 Returns:
258 Mapping[str, str]: The JSON-decoded response data.
259
260 Raises:
261 google.auth.exceptions.RefreshError: If the token endpoint returned
262 an error.
263 """
264
265 (
266 response_status_ok,
267 response_data,
268 retryable_error,
269 ) = _token_endpoint_request_no_throw(
270 request,
271 token_uri,
272 body,
273 access_token=access_token,
274 use_json=use_json,
275 can_retry=can_retry,
276 headers=headers,
277 **kwargs,
278 )
279 if not response_status_ok:
280 _handle_error_response(response_data, retryable_error)
281 return response_data
282
283
284def jwt_grant(request, token_uri, assertion, can_retry=True):
285 """Implements the JWT Profile for OAuth 2.0 Authorization Grants.
286
287 For more details, see `rfc7523 section 4`_.
288
289 Args:
290 request (google.auth.transport.Request): A callable used to make
291 HTTP requests.
292 token_uri (str): The OAuth 2.0 authorizations server's token endpoint
293 URI.
294 assertion (str): The OAuth 2.0 assertion.
295 can_retry (bool): Enable or disable request retry behavior.
296
297 Returns:
298 Tuple[str, Optional[datetime], Mapping[str, str]]: The access token,
299 expiration, and additional data returned by the token endpoint.
300
301 Raises:
302 google.auth.exceptions.RefreshError: If the token endpoint returned
303 an error.
304
305 .. _rfc7523 section 4: https://tools.ietf.org/html/rfc7523#section-4
306 """
307 body = {"assertion": assertion, "grant_type": _JWT_GRANT_TYPE}
308
309 response_data = _token_endpoint_request(
310 request,
311 token_uri,
312 body,
313 can_retry=can_retry,
314 headers={
315 metrics.API_CLIENT_HEADER: metrics.token_request_access_token_sa_assertion()
316 },
317 )
318
319 try:
320 access_token = response_data["access_token"]
321 except KeyError as caught_exc:
322 new_exc = exceptions.RefreshError(
323 "No access token in response.", response_data, retryable=False
324 )
325 raise new_exc from caught_exc
326
327 expiry = _parse_expiry(response_data)
328
329 return access_token, expiry, response_data
330
331
332def call_iam_generate_id_token_endpoint(
333 request,
334 iam_id_token_endpoint,
335 signer_email,
336 audience,
337 access_token,
338 universe_domain=credentials.DEFAULT_UNIVERSE_DOMAIN,
339):
340 """Call iam.generateIdToken endpoint to get ID token.
341
342 Args:
343 request (google.auth.transport.Request): A callable used to make
344 HTTP requests.
345 iam_id_token_endpoint (str): The IAM ID token endpoint to use.
346 signer_email (str): The signer email used to form the IAM
347 generateIdToken endpoint.
348 audience (str): The audience for the ID token.
349 access_token (str): The access token used to call the IAM endpoint.
350 universe_domain (str): The universe domain for the request. The
351 default is ``googleapis.com``.
352
353 Returns:
354 Tuple[str, datetime]: The ID token and expiration.
355 """
356 body = {"audience": audience, "includeEmail": "true", "useEmailAzp": "true"}
357
358 response_data = _token_endpoint_request(
359 request,
360 iam_id_token_endpoint.replace(
361 credentials.DEFAULT_UNIVERSE_DOMAIN, universe_domain
362 ).format(signer_email),
363 body,
364 access_token=access_token,
365 use_json=True,
366 )
367
368 try:
369 id_token = response_data["token"]
370 except KeyError as caught_exc:
371 new_exc = exceptions.RefreshError(
372 "No ID token in response.", response_data, retryable=False
373 )
374 raise new_exc from caught_exc
375
376 payload = jwt.decode(id_token, verify=False)
377 expiry = _helpers.utcfromtimestamp(payload["exp"])
378
379 return id_token, expiry
380
381
382def id_token_jwt_grant(request, token_uri, assertion, can_retry=True):
383 """Implements the JWT Profile for OAuth 2.0 Authorization Grants, but
384 requests an OpenID Connect ID Token instead of an access token.
385
386 This is a variant on the standard JWT Profile that is currently unique
387 to Google. This was added for the benefit of authenticating to services
388 that require ID Tokens instead of access tokens or JWT bearer tokens.
389
390 Args:
391 request (google.auth.transport.Request): A callable used to make
392 HTTP requests.
393 token_uri (str): The OAuth 2.0 authorization server's token endpoint
394 URI.
395 assertion (str): JWT token signed by a service account. The token's
396 payload must include a ``target_audience`` claim.
397 can_retry (bool): Enable or disable request retry behavior.
398
399 Returns:
400 Tuple[str, Optional[datetime], Mapping[str, str]]:
401 The (encoded) Open ID Connect ID Token, expiration, and additional
402 data returned by the endpoint.
403
404 Raises:
405 google.auth.exceptions.RefreshError: If the token endpoint returned
406 an error.
407 """
408 body = {"assertion": assertion, "grant_type": _JWT_GRANT_TYPE}
409
410 response_data = _token_endpoint_request(
411 request,
412 token_uri,
413 body,
414 can_retry=can_retry,
415 headers={
416 metrics.API_CLIENT_HEADER: metrics.token_request_id_token_sa_assertion()
417 },
418 )
419
420 try:
421 id_token = response_data["id_token"]
422 except KeyError as caught_exc:
423 new_exc = exceptions.RefreshError(
424 "No ID token in response.", response_data, retryable=False
425 )
426 raise new_exc from caught_exc
427
428 payload = jwt.decode(id_token, verify=False)
429 expiry = _helpers.utcfromtimestamp(payload["exp"])
430
431 return id_token, expiry, response_data
432
433
434def _handle_refresh_grant_response(response_data, refresh_token):
435 """Extract tokens from refresh grant response.
436
437 Args:
438 response_data (Mapping[str, str]): Refresh grant response data.
439 refresh_token (str): Current refresh token.
440
441 Returns:
442 Tuple[str, str, Optional[datetime], Mapping[str, str]]: The access token,
443 refresh token, expiration, and additional data returned by the token
444 endpoint. If response_data doesn't have refresh token, then the current
445 refresh token will be returned.
446
447 Raises:
448 google.auth.exceptions.RefreshError: If the token endpoint returned
449 an error.
450 """
451 try:
452 access_token = response_data["access_token"]
453 except KeyError as caught_exc:
454 new_exc = exceptions.RefreshError(
455 "No access token in response.", response_data, retryable=False
456 )
457 raise new_exc from caught_exc
458
459 refresh_token = response_data.get("refresh_token", refresh_token)
460 expiry = _parse_expiry(response_data)
461
462 return access_token, refresh_token, expiry, response_data
463
464
465def refresh_grant(
466 request,
467 token_uri,
468 refresh_token,
469 client_id,
470 client_secret,
471 scopes=None,
472 rapt_token=None,
473 can_retry=True,
474):
475 """Implements the OAuth 2.0 refresh token grant.
476
477 For more details, see `rfc678 section 6`_.
478
479 Args:
480 request (google.auth.transport.Request): A callable used to make
481 HTTP requests.
482 token_uri (str): The OAuth 2.0 authorizations server's token endpoint
483 URI.
484 refresh_token (str): The refresh token to use to get a new access
485 token.
486 client_id (str): The OAuth 2.0 application's client ID.
487 client_secret (str): The Oauth 2.0 appliaction's client secret.
488 scopes (Optional(Sequence[str])): Scopes to request. If present, all
489 scopes must be authorized for the refresh token. Useful if refresh
490 token has a wild card scope (e.g.
491 'https://www.googleapis.com/auth/any-api').
492 rapt_token (Optional(str)): The reauth Proof Token.
493 can_retry (bool): Enable or disable request retry behavior.
494
495 Returns:
496 Tuple[str, str, Optional[datetime], Mapping[str, str]]: The access
497 token, new or current refresh token, expiration, and additional data
498 returned by the token endpoint.
499
500 Raises:
501 google.auth.exceptions.RefreshError: If the token endpoint returned
502 an error.
503
504 .. _rfc6748 section 6: https://tools.ietf.org/html/rfc6749#section-6
505 """
506 body = {
507 "grant_type": _REFRESH_GRANT_TYPE,
508 "client_id": client_id,
509 "client_secret": client_secret,
510 "refresh_token": refresh_token,
511 }
512 if scopes:
513 body["scope"] = " ".join(scopes)
514 if rapt_token:
515 body["rapt"] = rapt_token
516
517 response_data = _token_endpoint_request(
518 request, token_uri, body, can_retry=can_retry
519 )
520 return _handle_refresh_grant_response(response_data, refresh_token)
521
522
523def _lookup_regional_access_boundary(request, url, headers=None, fail_fast=False):
524 """Implements the global lookup of a credential Regional Access Boundary.
525 For the lookup, we send a request to the global lookup endpoint and then
526 parse the response. Service account credentials, workload identity
527 pools and workforce pools implementation may have Regional Access Boundaries configured.
528 Args:
529 request (google.auth.transport.Request): A callable used to make
530 HTTP requests.
531 url (str): The Regional Access Boundary lookup url.
532 headers (Optional[Mapping[str, str]]): The headers for the request.
533 fail_fast (bool): Whether the lookup should fail fast (uses a short timeout and no retries).
534 Returns:
535 Optional[Mapping[str,list|str]]: A dictionary containing
536 "locations" as a list of allowed locations as strings and
537 "encodedLocations" as a hex string.
538 e.g:
539 {
540 "locations": [
541 "us-central1", "us-east1", "europe-west1", "asia-east1"
542 ],
543 "encodedLocations": "0xA30"
544 }
545 """
546
547 response_data = _lookup_regional_access_boundary_request(
548 request, url, headers=headers, fail_fast=fail_fast
549 )
550 if response_data is None:
551 # Error was already logged by _lookup_regional_access_boundary_request
552 return None
553
554 if not isinstance(response_data, dict) or "encodedLocations" not in response_data:
555 _LOGGER.error(
556 "Regional Access Boundary response malformed: missing 'encodedLocations' key in %s",
557 response_data,
558 )
559 return None
560 return response_data
561
562
563def _lookup_regional_access_boundary_request(
564 request, url, can_retry=True, headers=None, fail_fast=False
565):
566 """Makes a request to the Regional Access Boundary lookup endpoint.
567
568 Args:
569 request (google.auth.transport.Request): A callable used to make
570 HTTP requests.
571 url (str): The Regional Access Boundary lookup url.
572 can_retry (bool): Enable or disable request retry behavior. Defaults to true.
573 headers (Optional[Mapping[str, str]]): The headers for the request.
574 fail_fast (bool): Whether the lookup should fail fast (uses a short timeout and no retries).
575
576 Returns:
577 Optional[Mapping[str, str]]: The JSON-decoded response data on success, or None on failure.
578 """
579 (
580 response_status_ok,
581 response_data,
582 retryable_error,
583 ) = _lookup_regional_access_boundary_request_no_throw(
584 request, url, can_retry=can_retry, headers=headers, fail_fast=fail_fast
585 )
586 if not response_status_ok:
587 _LOGGER.debug(
588 "Regional Access Boundary HTTP request failed after retries: response_data=%s, retryable_error=%s",
589 response_data,
590 retryable_error,
591 )
592 return None
593 return response_data
594
595
596def _lookup_regional_access_boundary_request_no_throw(
597 request, url, can_retry=True, headers=None, fail_fast=False
598):
599 """Makes a request to the Regional Access Boundary lookup endpoint. This
600 function doesn't throw on response errors.
601
602 Args:
603 request (google.auth.transport.Request): A callable used to make
604 HTTP requests.
605 url (str): The Regional Access Boundary lookup url.
606 can_retry (bool): Enable or disable request retry behavior. Defaults to true.
607 headers (Optional[Mapping[str, str]]): The headers for the request.
608 fail_fast (bool): Whether the lookup should fail fast (uses a short timeout and no retries).
609
610 Returns:
611 Tuple(bool, Mapping[str, str], Optional[bool]): A boolean indicating
612 if the request is successful, a mapping for the JSON-decoded response
613 data and in the case of an error a boolean indicating if the error
614 is retryable.
615 """
616
617 response_data = {}
618 retryable_error = False
619
620 timeout = _BLOCKING_REGIONAL_ACCESS_BOUNDARY_LOOKUP_TIMEOUT if fail_fast else None
621 total_attempts = 1 if fail_fast else 6
622 retries = _exponential_backoff.ExponentialBackoff(total_attempts=total_attempts)
623
624 for _ in retries:
625 response = request(method="GET", url=url, headers=headers, timeout=timeout)
626 response_body = (
627 response.data.decode("utf-8")
628 if hasattr(response.data, "decode")
629 else response.data
630 )
631
632 try:
633 # response_body should be a JSON
634 response_data = json.loads(response_body)
635 except ValueError:
636 response_data = response_body
637
638 if response.status == http_client.OK:
639 return True, response_data, None
640
641 retryable_error = _can_retry(
642 status_code=response.status, response_data=response_data
643 )
644 # Add 502 (Bad Gateway) as a retryable error for RAB lookups.
645 if response.status == http_client.BAD_GATEWAY:
646 retryable_error = True
647
648 if not can_retry or not retryable_error:
649 return False, response_data, retryable_error
650
651 return False, response_data, retryable_error