-
Notifications
You must be signed in to change notification settings - Fork 35
Description
Thank you @Colin-b and all contributors for this awesome package!
Just wanted to share some thoughts on possibility of async OAuth2 flow. I don't know all the corner cases this package handles, so please let me know whenever I write something naive.
-
OAuth2 flows use a global cache with a (threading) lock. It's a problem for async code (Support for async httpx clientsย #48 (comment)), but also doesn't seem necessary. The token and lock could be simply instance variables. The only problem that that I can think of, is when user creates multiple instances with the same auth server and key. Is there a valid case for such usage?
-
Actually the cache holds two locks, one for accessing cache, the other for refreshing the lock. Again this seem unnecessary:
- when token is valid, the first concurrent call can acquire lock, check expiry and release it, while others wait for a short time.
- when token is expired, the first concurrent call can acquire the lock, check expiry and refresh the token, while others have to wait until it finishes
-
The OAuth2 flows use locks within
.auth_flow()method, against the Auth documentation.If the authentication scheme does I/O such as disk access or network calls, or uses
synchronization primitives such as locks, you should override.sync_auth_flow()
and/or.async_auth_flow()instead of.auth_flow()to provide specialized
implementations that will be used byClientandAsyncClientrespectively.This is addressed in Support for async httpx clientsย #48 patch.
Having two locks makes sense when storage and refresh were split. In this case acquiring the refresh lock could be pulled to.a/sync_auth_flow(). -
.auth_flow(), via.request_new_token()uses own instance ofhttpx.Client. Again, it doesn't seem necessary. Httpx already provides a/sync-portable protocol for making HTTP requests from Auth instances:response = yield request.If the auth server needs different transport options than the target server, mounts can be used. And if authentication is needed by the authentication server (meta-auth,
client_authparam), the yielded requests need to be processed by meta-auth first. -
Since all OAuth2 implementations in this package follow the pattern:
- try getting cached token
- if expired, fallback to
self.request_new_grant() - set header
steps 1+2 run with a lock, and 3 is very lightweight, the entire flow can run with a single lock.
When the above are addressed, supporting both sync and async should be as simple as implementing this class:
class LockingRefreshingAuth(Auth):
def __init__(self):
self._sync_lock = threading.Lock()
self._async_lock = anyio.Lock()
def sync_auth_flow(self, request: Request) -> Generator[Request, Response, None]:
if self.requires_request_body:
request.read()
with self._sync_lock:
# apply the loop over `self.auth_flow()`
flow = self.auth_flow(request)
...
async def async_auth_flow(self, request: Request) -> AsyncGenerator[Request, Response]:
# like above, but asyncImplementations would still yield their auth requests instead of directly using Client, and yield the user request before returning.
I've forked the repo to work on a POC. If it makes sense, and it's welcome, I'll make a PR. Meanwhile, comments are more than welcome.
edit 1: I've just noticed that auth token refresh requests also need to have authentication headers, and I guess that's why a client is used. But I still think it's unnecessary, and simply another Auth instance can be used
edit 2: It makes sense to have two locks if one lock protects a single Auth instance and the other the global cache.