Repository: googleapis/google-auth-library-python

Issue:
AuthorizedSession's _auth_request sub-session is never cleaned
### Environment details

  - OS: macOS
  - Python version: 3.9
  - `google-auth` version: 1.24.0

### Steps to reproduce (kinda)

[Py.test 6.2 enables hard errors on unhandled thread exceptions and resource errors](https://docs.pytest.org/en/stable/usage.html#warning-about-unraisable-exceptions-and-unhandled-thread-exceptions) such as leaking SSL sockets/sockets/FDs. I've spent the last couple hours debugging various

```
ResourceWarning: unclosed <ssl.SSLSocket fd=12, family=AddressFamily.AF_INET, type=SocketKind.SOCK_STREAM, proto=0, laddr=('192.168.1.23', 60522), raddr=('216.58.207.202', 443)>
```

warnings, and digging deep into `google.auth`, I found [this problematic segment](https://github.com/googleapis/google-auth-library-python/blob/647290a2dfc797067f7966c1dae512359e6bb7e7/google/auth/transport/requests.py#L333-L348).

As you may be aware, `requests.Session()` objects should be `.close()`d (or used as context managers) so the underlying connection pool gets shut down.

The above-linked code path creates a separate `requests.Session()` and a `google.auth.transport.requests.Request` object using it when no explicit `Request` object is passed in (and this seems to be the default for a trivial use of e.g. `google-cloud-storage`).

When that happens, the secondary session (only used for refreshing credentials) is never cleaned up even if the owning session might be.

#### Aside on spurious logging

As an aside, this same code path causes a

> urllib3.util.retry[85743] DEBUG: Converted retries value: 3 -> Retry(total=3, connect=None, read=None, redirect=None, status=None)

message to be logged since the sub-session's HTTP adapter is initialized with `max_retries=3`; this is indeed [what the requests manual](https://requests.readthedocs.io/en/latest/api/#requests.adapters.HTTPAdapter) says, but changing that to `max_retries=Retry(3, redirect=True)` (where `Retry` comes from `urllib3`) would fix that.

### Remedy

I think `AuthorizedSession` should remember whether it initialized this subsession, and if it did, have it clean it up on `close()`; something like

```python
def close(self):
    if self._auth_request_session:
        self._auth_request_session.close()
    super().close()
 ```

might do since `close()` is already automatically called by `requests.Session.__exit__()`, so careful users of `AuthorizedSession` will benefit from this immediately.

I'm willing to formalize this into a PR if the plan sounds good.

### Workaround

I'm currently making sure to close those pools with a hack like this.

```python
class CloseableStorageClient(storage.Client):
    def __enter__(self):
        return self

    def __exit__(self, *args):
        try:
            # Attempt closing the implicit sub-session
            # https://github.com/googleapis/google-auth-library-python/issues/658
            self._http._auth_request.session.close()
        except Exception:
            pass
        self._http.close()
```


Discussion:
@busunkim96 ,  This is possibly solved by https://github.com/googleapis/python-cloud-core/pull/100 . WDYT?
@tseaver Please can you take a look?

Resolving patch:
diff --git a/google/auth/transport/requests.py b/google/auth/transport/requests.py
--- a/google/auth/transport/requests.py
+++ b/google/auth/transport/requests.py
@@ -340,17 +340,19 @@ def __init__(
         self._default_host = default_host
 
         if auth_request is None:
-            auth_request_session = requests.Session()
+            self._auth_request_session = requests.Session()
 
             # Using an adapter to make HTTP requests robust to network errors.
             # This adapter retrys HTTP requests when network errors occur
             # and the requests seems safely retryable.
             retry_adapter = requests.adapters.HTTPAdapter(max_retries=3)
-            auth_request_session.mount("https://", retry_adapter)
+            self._auth_request_session.mount("https://", retry_adapter)
 
             # Do not pass `self` as the session here, as it can lead to
             # infinite recursion.
-            auth_request = Request(auth_request_session)
+            auth_request = Request(self._auth_request_session)
+        else:
+            self._auth_request_session = None
 
         # Request instance used by internal methods (for example,
         # credentials.refresh).
@@ -533,3 +535,8 @@ def request(
     def is_mtls(self):
         """Indicates if the created SSL channel is mutual TLS."""
         return self._is_mtls
+
+    def close(self):
+        if self._auth_request_session is not None:
+            self._auth_request_session.close()
+        super(AuthorizedSession, self).close()


Test patch:
diff --git a/system_tests/system_tests_sync/test_requests.py b/system_tests/system_tests_sync/test_requests.py
--- a/system_tests/system_tests_sync/test_requests.py
+++ b/system_tests/system_tests_sync/test_requests.py
@@ -32,8 +32,10 @@ def test_authorized_session_with_service_account_and_self_signed_jwt():
 
     # List Pub/Sub Topics through the REST API
     # https://cloud.google.com/pubsub/docs/reference/rest/v1/projects.topics/list
-    response = session.get("https://pubsub.googleapis.com/v1/projects/{}/topics".format(project_id))
-    response.raise_for_status()
+    url = "https://pubsub.googleapis.com/v1/projects/{}/topics".format(project_id)
+    with session:
+        response = session.get(url)
+        response.raise_for_status()
 
     # Check that self-signed JWT was created and is being used
     assert credentials._jwt_credentials is not None
diff --git a/tests/transport/test_requests.py b/tests/transport/test_requests.py
--- a/tests/transport/test_requests.py
+++ b/tests/transport/test_requests.py
@@ -213,7 +213,7 @@ def test_constructor_with_auth_request(self):
             mock.sentinel.credentials, auth_request=auth_request
         )
 
-        assert authed_session._auth_request == auth_request
+        assert authed_session._auth_request is auth_request
 
     def test_request_default_timeout(self):
         credentials = mock.Mock(wraps=CredentialsStub())
@@ -504,3 +504,22 @@ def test_configure_mtls_channel_without_client_cert_env(
         auth_session.configure_mtls_channel(mock_callback)
         assert not auth_session.is_mtls
         mock_callback.assert_not_called()
+
+    def test_close_wo_passed_in_auth_request(self):
+        authed_session = google.auth.transport.requests.AuthorizedSession(
+            mock.sentinel.credentials
+        )
+        authed_session._auth_request_session = mock.Mock(spec=["close"])
+
+        authed_session.close()
+
+        authed_session._auth_request_session.close.assert_called_once_with()
+
+    def test_close_w_passed_in_auth_request(self):
+        http = mock.create_autospec(requests.Session)
+        auth_request = google.auth.transport.requests.Request(http)
+        authed_session = google.auth.transport.requests.AuthorizedSession(
+            mock.sentinel.credentials, auth_request=auth_request
+        )
+
+        authed_session.close()  # no raise