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