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"""Transport adapter for Requests."""
16
17from __future__ import absolute_import
18
19import functools
20import http.client as http_client
21import logging
22import numbers
23import time
24from typing import Optional
25
26try:
27 import requests
28except ImportError as caught_exc: # pragma: NO COVER
29 raise ImportError(
30 "The requests library is not installed from please install the requests package to use the requests transport."
31 ) from caught_exc
32import requests.adapters # pylint: disable=ungrouped-imports
33import requests.exceptions # pylint: disable=ungrouped-imports
34from requests.packages.urllib3.util.ssl_ import ( # type: ignore
35 create_urllib3_context,
36) # pylint: disable=ungrouped-imports
37
38from google.auth import _helpers
39from google.auth import exceptions
40from google.auth import transport
41from google.auth.transport import _mtls_helper
42import google.auth.transport._mtls_helper
43from google.oauth2 import service_account
44
45_LOGGER = logging.getLogger(__name__)
46
47_DEFAULT_TIMEOUT = 120 # in seconds
48
49
50class _Response(transport.Response):
51 """Requests transport response adapter.
52
53 Args:
54 response (requests.Response): The raw Requests response.
55 """
56
57 def __init__(self, response):
58 self._response = response
59
60 @property
61 def status(self):
62 return self._response.status_code
63
64 @property
65 def headers(self):
66 return self._response.headers
67
68 @property
69 def data(self):
70 return self._response.content
71
72
73class TimeoutGuard(object):
74 """A context manager raising an error if the suite execution took too long.
75
76 Args:
77 timeout (Union[None, Union[float, Tuple[float, float]]]):
78 The maximum number of seconds a suite can run without the context
79 manager raising a timeout exception on exit. If passed as a tuple,
80 the smaller of the values is taken as a timeout. If ``None``, a
81 timeout error is never raised.
82 timeout_error_type (Optional[Exception]):
83 The type of the error to raise on timeout. Defaults to
84 :class:`requests.exceptions.Timeout`.
85 """
86
87 def __init__(self, timeout, timeout_error_type=requests.exceptions.Timeout):
88 self._timeout = timeout
89 self.remaining_timeout = timeout
90 self._timeout_error_type = timeout_error_type
91
92 def __enter__(self):
93 self._start = time.time()
94 return self
95
96 def __exit__(self, exc_type, exc_value, traceback):
97 if exc_value:
98 return # let the error bubble up automatically
99
100 if self._timeout is None:
101 return # nothing to do, the timeout was not specified
102
103 elapsed = time.time() - self._start
104 deadline_hit = False
105
106 if isinstance(self._timeout, numbers.Number):
107 self.remaining_timeout = self._timeout - elapsed
108 deadline_hit = self.remaining_timeout <= 0
109 else:
110 self.remaining_timeout = tuple(x - elapsed for x in self._timeout)
111 deadline_hit = min(self.remaining_timeout) <= 0
112
113 if deadline_hit:
114 raise self._timeout_error_type()
115
116
117class Request(transport.Request):
118 """Requests request adapter.
119
120 This class is used internally for making requests using various transports
121 in a consistent way. If you use :class:`AuthorizedSession` you do not need
122 to construct or use this class directly.
123
124 This class can be useful if you want to manually refresh a
125 :class:`~google.auth.credentials.Credentials` instance::
126
127 import google.auth.transport.requests
128 import requests
129
130 request = google.auth.transport.requests.Request()
131
132 credentials.refresh(request)
133
134 Args:
135 session (requests.Session): An instance :class:`requests.Session` used
136 to make HTTP requests. If not specified, a session will be created.
137
138 .. automethod:: __call__
139 """
140
141 def __init__(self, session: Optional[requests.Session] = None) -> None:
142 if not session:
143 session = requests.Session()
144
145 self.session = session
146
147 def __del__(self):
148 try:
149 if hasattr(self, "session") and self.session is not None:
150 self.session.close()
151 except TypeError:
152 # NOTE: For certain Python binary built, the queue.Empty exception
153 # might not be considered a normal Python exception causing
154 # TypeError.
155 pass
156
157 def __call__(
158 self,
159 url,
160 method="GET",
161 body=None,
162 headers=None,
163 timeout=_DEFAULT_TIMEOUT,
164 **kwargs
165 ):
166 """Make an HTTP request using requests.
167
168 Args:
169 url (str): The URI to be requested.
170 method (str): The HTTP method to use for the request. Defaults
171 to 'GET'.
172 body (bytes): The payload or body in HTTP request.
173 headers (Mapping[str, str]): Request headers.
174 timeout (Optional[int]): The number of seconds to wait for a
175 response from the server. If not specified or if None, the
176 requests default timeout will be used.
177 kwargs: Additional arguments passed through to the underlying
178 requests :meth:`~requests.Session.request` method.
179
180 Returns:
181 google.auth.transport.Response: The HTTP response.
182
183 Raises:
184 google.auth.exceptions.TransportError: If any exception occurred.
185 """
186 try:
187 _helpers.request_log(_LOGGER, method, url, body, headers)
188 response = self.session.request(
189 method, url, data=body, headers=headers, timeout=timeout, **kwargs
190 )
191 _helpers.response_log(_LOGGER, response)
192 return _Response(response)
193 except requests.exceptions.RequestException as caught_exc:
194 new_exc = exceptions.TransportError(caught_exc)
195 raise new_exc from caught_exc
196
197
198class _MutualTlsAdapter(requests.adapters.HTTPAdapter):
199 """
200 A TransportAdapter that enables mutual TLS.
201
202 Args:
203 cert (bytes): client certificate in PEM format
204 key (bytes): client private key in PEM format
205
206 Raises:
207 ImportError: if certifi or pyOpenSSL is not installed
208 OpenSSL.crypto.Error: if client cert or key is invalid
209 """
210
211 def __init__(self, cert, key):
212 import certifi
213 from OpenSSL import crypto
214 import urllib3.contrib.pyopenssl # type: ignore
215
216 urllib3.contrib.pyopenssl.inject_into_urllib3()
217
218 pkey = crypto.load_privatekey(crypto.FILETYPE_PEM, key)
219 x509 = crypto.load_certificate(crypto.FILETYPE_PEM, cert)
220
221 ctx_poolmanager = create_urllib3_context()
222 ctx_poolmanager.load_verify_locations(cafile=certifi.where())
223 ctx_poolmanager._ctx.use_certificate(x509)
224 ctx_poolmanager._ctx.use_privatekey(pkey)
225 self._ctx_poolmanager = ctx_poolmanager
226
227 ctx_proxymanager = create_urllib3_context()
228 ctx_proxymanager.load_verify_locations(cafile=certifi.where())
229 ctx_proxymanager._ctx.use_certificate(x509)
230 ctx_proxymanager._ctx.use_privatekey(pkey)
231 self._ctx_proxymanager = ctx_proxymanager
232
233 super(_MutualTlsAdapter, self).__init__()
234
235 def init_poolmanager(self, *args, **kwargs):
236 kwargs["ssl_context"] = self._ctx_poolmanager
237 super(_MutualTlsAdapter, self).init_poolmanager(*args, **kwargs)
238
239 def proxy_manager_for(self, *args, **kwargs):
240 kwargs["ssl_context"] = self._ctx_proxymanager
241 return super(_MutualTlsAdapter, self).proxy_manager_for(*args, **kwargs)
242
243
244class _MutualTlsOffloadAdapter(requests.adapters.HTTPAdapter):
245 """
246 A TransportAdapter that enables mutual TLS and offloads the client side
247 signing operation to the signing library.
248
249 Args:
250 enterprise_cert_file_path (str): the path to a enterprise cert JSON
251 file. The file should contain the following field:
252
253 {
254 "libs": {
255 "signer_library": "...",
256 "offload_library": "..."
257 }
258 }
259
260 Raises:
261 ImportError: if certifi or pyOpenSSL is not installed
262 google.auth.exceptions.MutualTLSChannelError: If mutual TLS channel
263 creation failed for any reason.
264 """
265
266 def __init__(self, enterprise_cert_file_path):
267 import certifi
268 from google.auth.transport import _custom_tls_signer
269
270 self.signer = _custom_tls_signer.CustomTlsSigner(enterprise_cert_file_path)
271 self.signer.load_libraries()
272
273 import urllib3.contrib.pyopenssl
274
275 urllib3.contrib.pyopenssl.inject_into_urllib3()
276
277 poolmanager = create_urllib3_context()
278 poolmanager.load_verify_locations(cafile=certifi.where())
279 self.signer.attach_to_ssl_context(poolmanager)
280 self._ctx_poolmanager = poolmanager
281
282 proxymanager = create_urllib3_context()
283 proxymanager.load_verify_locations(cafile=certifi.where())
284 self.signer.attach_to_ssl_context(proxymanager)
285 self._ctx_proxymanager = proxymanager
286
287 super(_MutualTlsOffloadAdapter, self).__init__()
288
289 def init_poolmanager(self, *args, **kwargs):
290 kwargs["ssl_context"] = self._ctx_poolmanager
291 super(_MutualTlsOffloadAdapter, self).init_poolmanager(*args, **kwargs)
292
293 def proxy_manager_for(self, *args, **kwargs):
294 kwargs["ssl_context"] = self._ctx_proxymanager
295 return super(_MutualTlsOffloadAdapter, self).proxy_manager_for(*args, **kwargs)
296
297
298class AuthorizedSession(requests.Session):
299 """A Requests Session class with credentials.
300
301 This class is used to perform requests to API endpoints that require
302 authorization::
303
304 from google.auth.transport.requests import AuthorizedSession
305
306 authed_session = AuthorizedSession(credentials)
307
308 response = authed_session.request(
309 'GET', 'https://www.googleapis.com/storage/v1/b')
310
311
312 The underlying :meth:`request` implementation handles adding the
313 credentials' headers to the request and refreshing credentials as needed.
314
315 This class also supports mutual TLS via :meth:`configure_mtls_channel`
316 method. In order to use this method, the `GOOGLE_API_USE_CLIENT_CERTIFICATE`
317 environment variable must be explicitly set to ``true``, otherwise it does
318 nothing. Assume the environment is set to ``true``, the method behaves in the
319 following manner:
320
321 If client_cert_callback is provided, client certificate and private
322 key are loaded using the callback; if client_cert_callback is None,
323 application default SSL credentials will be used. Exceptions are raised if
324 there are problems with the certificate, private key, or the loading process,
325 so it should be called within a try/except block.
326
327 First we set the environment variable to ``true``, then create an :class:`AuthorizedSession`
328 instance and specify the endpoints::
329
330 regular_endpoint = 'https://pubsub.googleapis.com/v1/projects/{my_project_id}/topics'
331 mtls_endpoint = 'https://pubsub.mtls.googleapis.com/v1/projects/{my_project_id}/topics'
332
333 authed_session = AuthorizedSession(credentials)
334
335 Now we can pass a callback to :meth:`configure_mtls_channel`::
336
337 def my_cert_callback():
338 # some code to load client cert bytes and private key bytes, both in
339 # PEM format.
340 some_code_to_load_client_cert_and_key()
341 if loaded:
342 return cert, key
343 raise MyClientCertFailureException()
344
345 # Always call configure_mtls_channel within a try/except block.
346 try:
347 authed_session.configure_mtls_channel(my_cert_callback)
348 except:
349 # handle exceptions.
350
351 if authed_session.is_mtls:
352 response = authed_session.request('GET', mtls_endpoint)
353 else:
354 response = authed_session.request('GET', regular_endpoint)
355
356
357 You can alternatively use application default SSL credentials like this::
358
359 try:
360 authed_session.configure_mtls_channel()
361 except:
362 # handle exceptions.
363
364 Args:
365 credentials (google.auth.credentials.Credentials): The credentials to
366 add to the request.
367 refresh_status_codes (Sequence[int]): Which HTTP status codes indicate
368 that credentials should be refreshed and the request should be
369 retried.
370 max_refresh_attempts (int): The maximum number of times to attempt to
371 refresh the credentials and retry the request.
372 refresh_timeout (Optional[int]): The timeout value in seconds for
373 credential refresh HTTP requests.
374 auth_request (google.auth.transport.requests.Request):
375 (Optional) An instance of
376 :class:`~google.auth.transport.requests.Request` used when
377 refreshing credentials. If not passed,
378 an instance of :class:`~google.auth.transport.requests.Request`
379 is created.
380 default_host (Optional[str]): A host like "pubsub.googleapis.com".
381 This is used when a self-signed JWT is created from service
382 account credentials.
383 """
384
385 def __init__(
386 self,
387 credentials,
388 refresh_status_codes=transport.DEFAULT_REFRESH_STATUS_CODES,
389 max_refresh_attempts=transport.DEFAULT_MAX_REFRESH_ATTEMPTS,
390 refresh_timeout=None,
391 auth_request=None,
392 default_host=None,
393 ):
394 super(AuthorizedSession, self).__init__()
395 self.credentials = credentials
396 self._refresh_status_codes = refresh_status_codes
397 self._max_refresh_attempts = max_refresh_attempts
398 self._refresh_timeout = refresh_timeout
399 self._is_mtls = False
400 self._default_host = default_host
401
402 if auth_request is None:
403 self._auth_request_session = requests.Session()
404
405 # Using an adapter to make HTTP requests robust to network errors.
406 # This adapter retrys HTTP requests when network errors occur
407 # and the requests seems safely retryable.
408 retry_adapter = requests.adapters.HTTPAdapter(max_retries=3)
409 self._auth_request_session.mount("https://", retry_adapter)
410
411 # Do not pass `self` as the session here, as it can lead to
412 # infinite recursion.
413 auth_request = Request(self._auth_request_session)
414 else:
415 self._auth_request_session = None
416
417 # Request instance used by internal methods (for example,
418 # credentials.refresh).
419 self._auth_request = auth_request
420
421 # https://google.aip.dev/auth/4111
422 # Attempt to use self-signed JWTs when a service account is used.
423 if isinstance(self.credentials, service_account.Credentials):
424 self.credentials._create_self_signed_jwt(
425 "https://{}/".format(self._default_host) if self._default_host else None
426 )
427
428 def configure_mtls_channel(self, client_cert_callback=None):
429 """Configure the client certificate and key for SSL connection.
430
431 The function does nothing unless `GOOGLE_API_USE_CLIENT_CERTIFICATE` is
432 explicitly set to `true`. In this case if client certificate and key are
433 successfully obtained (from the given client_cert_callback or from application
434 default SSL credentials), a :class:`_MutualTlsAdapter` instance will be mounted
435 to "https://" prefix.
436
437 Args:
438 client_cert_callback (Optional[Callable[[], (bytes, bytes)]]):
439 The optional callback returns the client certificate and private
440 key bytes both in PEM format.
441 If the callback is None, application default SSL credentials
442 will be used.
443
444 Raises:
445 google.auth.exceptions.MutualTLSChannelError: If mutual TLS channel
446 creation failed for any reason.
447 """
448 use_client_cert = google.auth.transport._mtls_helper.check_use_client_cert()
449 if not use_client_cert:
450 self._is_mtls = False
451 return
452 try:
453 import OpenSSL
454 except ImportError as caught_exc:
455 new_exc = exceptions.MutualTLSChannelError(caught_exc)
456 raise new_exc from caught_exc
457
458 try:
459 (
460 self._is_mtls,
461 cert,
462 key,
463 ) = google.auth.transport._mtls_helper.get_client_cert_and_key(
464 client_cert_callback
465 )
466
467 if self._is_mtls:
468 mtls_adapter = _MutualTlsAdapter(cert, key)
469 self._cached_cert = cert
470 self.mount("https://", mtls_adapter)
471 except (
472 exceptions.ClientCertError,
473 ImportError,
474 OpenSSL.crypto.Error,
475 ) as caught_exc:
476 new_exc = exceptions.MutualTLSChannelError(caught_exc)
477 raise new_exc from caught_exc
478
479 def request(
480 self,
481 method,
482 url,
483 data=None,
484 headers=None,
485 max_allowed_time=None,
486 timeout=_DEFAULT_TIMEOUT,
487 **kwargs
488 ):
489 """Implementation of Requests' request.
490
491 Args:
492 timeout (Optional[Union[float, Tuple[float, float]]]):
493 The amount of time in seconds to wait for the server response
494 with each individual request. Can also be passed as a tuple
495 ``(connect_timeout, read_timeout)``. See :meth:`requests.Session.request`
496 documentation for details.
497 max_allowed_time (Optional[float]):
498 If the method runs longer than this, a ``Timeout`` exception is
499 automatically raised. Unlike the ``timeout`` parameter, this
500 value applies to the total method execution time, even if
501 multiple requests are made under the hood.
502
503 Mind that it is not guaranteed that the timeout error is raised
504 at ``max_allowed_time``. It might take longer, for example, if
505 an underlying request takes a lot of time, but the request
506 itself does not timeout, e.g. if a large file is being
507 transmitted. The timeout error will be raised after such
508 request completes.
509 Raises:
510 google.auth.exceptions.MutualTLSChannelError: If mutual TLS
511 channel creation fails for any reason.
512 ValueError: If the client certificate is invalid.
513 """
514 # pylint: disable=arguments-differ
515 # Requests has a ton of arguments to request, but only two
516 # (method, url) are required. We pass through all of the other
517 # arguments to super, so no need to exhaustively list them here.
518
519 # Use a kwarg for this instead of an attribute to maintain
520 # thread-safety.
521 _credential_refresh_attempt = kwargs.pop("_credential_refresh_attempt", 0)
522
523 # Make a copy of the headers. They will be modified by the credentials
524 # and we want to pass the original headers if we recurse.
525 request_headers = headers.copy() if headers is not None else {}
526
527 # Do not apply the timeout unconditionally in order to not override the
528 # _auth_request's default timeout.
529 auth_request = (
530 self._auth_request
531 if timeout is None
532 else functools.partial(self._auth_request, timeout=timeout)
533 )
534
535 remaining_time = max_allowed_time
536
537 with TimeoutGuard(remaining_time) as guard:
538 self.credentials.before_request(auth_request, method, url, request_headers)
539 remaining_time = guard.remaining_timeout
540
541 with TimeoutGuard(remaining_time) as guard:
542 _helpers.request_log(_LOGGER, method, url, data, headers)
543 response = super(AuthorizedSession, self).request(
544 method,
545 url,
546 data=data,
547 headers=request_headers,
548 timeout=timeout,
549 **kwargs
550 )
551 remaining_time = guard.remaining_timeout
552
553 # If the response indicated that the credentials needed to be
554 # refreshed, then refresh the credentials and re-attempt the
555 # request.
556 # A stored token may expire between the time it is retrieved and
557 # the time the request is made, so we may need to try twice.
558 if (
559 response.status_code in self._refresh_status_codes
560 and _credential_refresh_attempt < self._max_refresh_attempts
561 ):
562 # Handle unauthorized permission error(401 status code)
563 if response.status_code == http_client.UNAUTHORIZED:
564 if self.is_mtls:
565 (
566 call_cert_bytes,
567 call_key_bytes,
568 cached_fingerprint,
569 current_cert_fingerprint,
570 ) = _mtls_helper.check_parameters_for_unauthorized_response(
571 self._cached_cert
572 )
573 if cached_fingerprint != current_cert_fingerprint:
574 try:
575 _LOGGER.info(
576 "Client certificate has changed, reconfiguring mTLS "
577 "channel."
578 )
579 self.configure_mtls_channel(
580 lambda: (call_cert_bytes, call_key_bytes)
581 )
582 except Exception as e:
583 _LOGGER.error("Failed to reconfigure mTLS channel: %s", e)
584 raise exceptions.MutualTLSChannelError(
585 "Failed to reconfigure mTLS channel"
586 ) from e
587 else:
588 _LOGGER.info(
589 "Skipping reconfiguration of mTLS channel because the client"
590 " certificate has not changed."
591 )
592 _LOGGER.info(
593 "Refreshing credentials due to a %s response. Attempt %s/%s.",
594 response.status_code,
595 _credential_refresh_attempt + 1,
596 self._max_refresh_attempts,
597 )
598
599 # Do not apply the timeout unconditionally in order to not override the
600 # _auth_request's default timeout.
601 auth_request = (
602 self._auth_request
603 if timeout is None
604 else functools.partial(self._auth_request, timeout=timeout)
605 )
606
607 with TimeoutGuard(remaining_time) as guard:
608 self.credentials.refresh(auth_request)
609 remaining_time = guard.remaining_timeout
610
611 # Recurse. Pass in the original headers, not our modified set, but
612 # do pass the adjusted max allowed time (i.e. the remaining total time).
613 return self.request(
614 method,
615 url,
616 data=data,
617 headers=headers,
618 max_allowed_time=remaining_time,
619 timeout=timeout,
620 _credential_refresh_attempt=_credential_refresh_attempt + 1,
621 **kwargs
622 )
623
624 return response
625
626 @property
627 def is_mtls(self):
628 """Indicates if the created SSL channel is mutual TLS."""
629 return self._is_mtls
630
631 def close(self):
632 if self._auth_request_session is not None:
633 self._auth_request_session.close()
634 super(AuthorizedSession, self).close()