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