Repository: jpetrucciani/hubspot3

Issue:
Proposal to improve support for oauth2 authentication with multiple clients
## Introduction

Hi @jpetrucciani, I've submitted a small handful of PRs in the past and I'm a regular user/advocate of the project. The company I work at would like to use the library in a distributed environment with oauth2 authentication, but we've run into contention issues where the different containers essentially fight over access tokens because only one can be active at any given time. This leads to almost every request initially failing with 401 errors and then only working after a new access token has been requested from Hubspot. It would be ideal if we could share the tokens across workers through an in-memory store like Redis to avoid these issues. This is a significant enough change that I thought it would be good to open an issue to discuss the approach before implementing it (and I would be happy to implement it, this isn't a request for you to do so).

## How oauth2 is currently handled

The easiest way to initialize the client is through the [`Hubspot3`](https://github.com/jpetrucciani/hubspot3/blob/adca3f8e3e85daa149320eb5229a877eefec1143/hubspot3/__init__.py#L83) class in `__init__.py`. This supports four options which are all required for [Hubspot's oauth authentication scheme](https://developers.hubspot.com/docs/methods/oauth2/oauth2-overview):

- `client_id` - The client ID of the app that's configured for oauth2 authentication. This is non-sensitive and doesn't change.
- `client_secret` - The client secret for the app. This is sensitive and doesn't change.
- `access_token` - This is a temporary token that provides access to the API as an alternative to using the API key. This is sensitive and expires after 6 hours.
- `refresh_token` - This is a temporary token that is required to generate new access tokens after they have expired. This is sensitive, and although it doesn't explicitly expire, it will rotate regularly through the course of requesting new access tokens.

These are stored in a dictionary called `auth` on a `Hubspot3` instance after initialization, and the arguments are passed into the different client classes when they're accessed as properties. For example, accessing the `contacts` property on the client will initialize a new `ContactsClient` instance via this code:

```python
    @property
    def contacts(self):
        """returns a hubspot3 contacts client"""
        from hubspot3.contacts import ContactsClient

        return ContactsClient(**self.auth, **self.options)
```

All of the various clients inherit from [BaseClient](https://github.com/jpetrucciani/hubspot3/blob/adca3f8e3e85daa149320eb5229a877eefec1143/hubspot3/base.py#L30) which stores the credentials on instance properties when initialized. The credentials are then used in [`BaseClient._call_raw()`](https://github.com/jpetrucciani/hubspot3/blob/adca3f8e3e85daa149320eb5229a877eefec1143/hubspot3/base.py#L215) whenever a request is made, and a new access token is requested if a `HubspotUnauthorized` exception is raised. The `access_token` and `refresh_token` properties are then replaced on the instance by [this code](https://github.com/jpetrucciani/hubspot3/blob/adca3f8e3e85daa149320eb5229a877eefec1143/hubspot3/base.py#L288):

```python
                        client = OAuth2Client(**self.options)
                        refresh_result = client.refresh_tokens(
                            client_id=self.client_id,
                            client_secret=self.client_secret,
                            refresh_token=self.refresh_token,
                        )
                        self.access_token = refresh_result["access_token"]
                        self.refresh_token = refresh_result["refresh_token"]
```

The `OAuth2Client` class handles refreshing the tokens, but it isn't responsible for storage or maintenance of the tokens.

## Proposal for supporting dynamic storage of tokens

A relatively simple and backwards compatible change is adding two new initialization parameters to the `Hubspot3` and `BaseClient` classes:

- `oauth2_token_setter` - A setter with type `typing.Callable([BaseClient, typing.Literal['access_token', 'refresh_token'], str, str)`, a signature of `callable(base_client, token_type, client_id, token)`, and a default value of `lambda self, token_type, client_id, token: setattr(self, token_type, token)`. This would be used to store either of the tokens through either some external mechanism or just on the instance. The client ID is included to accommodate for the possibility of multiple clients being used in the same application.

- `oauth2_token_getter` - A getter with type `typing.Callable([BaseClient, typing.Literal['access_token', 'refresh_token'], str)`, a signature of `callable(token_type, client_id)`, and a default value of `lambda self, token_type, client_id: getattr(self, token_type)`. This would be used to retrieve either of the tokens through some external mechanism.

The logic in the `__init__()` methods of `BaseClient` and `Hubspot3` as well as the `BaseClient._call_raw()` method would then use the setter and getter in place of direct attribute access. The `access_token`/`refresh_token` attributes would still be used by default, and this should be completely backward compatible while also supporting more sophisticated storage mechanisms.

## Conclusion

Apologies for the verbose issue, but I wanted to clearly lay out what I'm proposing. If this sounds good to you, then I'll go ahead and implement it.

Discussion:

Patch:
diff --git a/hubspot3/base.py b/hubspot3/base.py
index 7f9f82f..95937e5 100644
--- a/hubspot3/base.py
+++ b/hubspot3/base.py
@@ -10,7 +10,8 @@ import urllib.request
 import urllib.parse
 import urllib.error
 import zlib
-from typing import List, Union
+from typing import Callable, List, Optional, Union
+from typing_extensions import Literal
 from hubspot3 import utils
 from hubspot3.utils import force_utf8
 from hubspot3.error import (
@@ -30,6 +31,9 @@ from hubspot3.error import (
 class BaseClient:
     """Base abstract object for interacting with the HubSpot APIs"""
 
+    # These rules are too restrictive for the `__init__()` method and attributes.
+    # pylint: disable=too-many-arguments,too-many-instance-attributes
+
     # Controls how long we sleep for during retries, overridden by unittests
     # so tests run faster
     sleep_multiplier = 1
@@ -41,6 +45,12 @@ class BaseClient:
         refresh_token: str = None,
         client_id: str = None,
         client_secret: str = None,
+        oauth2_token_getter: Optional[
+            Callable[[Literal["access_token", "refresh_token"], str], str]
+        ] = None,
+        oauth2_token_setter: Optional[
+            Callable[[Literal["access_token", "refresh_token"], str, str], None]
+        ] = None,
         timeout: int = 10,
         mixins: List = None,
         api_base: str = "api.hubapi.com",
@@ -58,10 +68,18 @@ class BaseClient:
                 self.__class__.__bases__ = (mixin_class,) + self.__class__.__bases__
 
         self.api_key = api_key
-        self.access_token = access_token
-        self.refresh_token = refresh_token
+        # These are used as fallbacks if there aren't setters/getters, or if no remote tokens can be
+        # found. The properties without `__` prefixes should generally be used instead of these.
+        self.__access_token = access_token
+        self.__refresh_token = refresh_token
         self.client_id = client_id
         self.client_secret = client_secret
+        if (oauth2_token_getter is None) != (oauth2_token_setter is None):
+            raise HubspotBadConfig(
+                "You must either specify both the oauth2 token setter and getter, or neither."
+            )
+        self.oauth2_token_getter = oauth2_token_getter
+        self.oauth2_token_setter = oauth2_token_setter
         self.log = utils.get_log("hubspot3")
         if not disable_auth:
             if self.api_key and self.access_token:
@@ -88,8 +106,41 @@ class BaseClient:
             "api_key": self.api_key,
             "access_token": self.access_token,
             "refresh_token": self.refresh_token,
+            "oauth2_token_getter": self.oauth2_token_getter,
+            "oauth2_token_setter": self.oauth2_token_setter,
         }
 
+    @property
+    def access_token(self):
+        if self.oauth2_token_getter:
+            return (
+                self.oauth2_token_getter("access_token", self.client_id)
+                or self.__access_token
+            )
+        return self.__access_token
+
+    @access_token.setter
+    def access_token(self, access_token):
+        if self.oauth2_token_setter:
+            self.oauth2_token_setter("access_token", self.client_id, access_token)
+        self.__access_token = access_token
+
+    @property
+    def refresh_token(self):
+        if self.oauth2_token_getter:
+            return (
+                self.oauth2_token_getter("refresh_token", self.client_id)
+                or self.__refresh_token
+            )
+        return self.__refresh_token
+
+    @refresh_token.setter
+    def refresh_token(self, refresh_token):
+        if self.oauth2_token_setter:
+            self.oauth2_token_setter("refresh_token", self.client_id, refresh_token)
+        else:
+            self.__refresh_token = refresh_token
+
     def _prepare_connection_type(self):
         connection_types = {
             "http": http.client.HTTPConnection,


Test patch:
diff --git a/hubspot3/test/test_lines.py b/hubspot3/test/test_lines.py
index a606a5f..35fab46 100644
--- a/hubspot3/test/test_lines.py
+++ b/hubspot3/test/test_lines.py
@@ -130,6 +130,10 @@ class TestLinesClient(object):
         mock_instance = mock_associations_client.return_value
         lines_client.link_line_item_to_deal(1, 1)
         mock_associations_client.assert_called_with(
-            access_token=None, api_key=None, refresh_token=None
+            access_token=None,
+            api_key=None,
+            refresh_token=None,
+            oauth2_token_getter=None,
+            oauth2_token_setter=None,
         )
         mock_instance.link_line_item_to_deal.assert_called_with(1, 1)