fix(auth): serialise the token refresh across processes
The login cache is shared by every mdrs process, but the refresh was guarded by a lock that only reaches inside one. Concurrent runs each sent the same refresh token, and a provider that rotates them accepts the first and refuses the rest. - hold a lock that spans processes across the whole read-refresh- write, checking cheaply first so ordinary requests never take it - write the cache through a temporary file: opening it for writing truncates it, and a reader landing in that window found it empty and threw the session away - take the lock for every write, not just the refresh, so a login running beside one cannot be silently reverted - wait for a busy lock on Windows rather than giving up after the ten attempts msvcrt allows, and retry the rename it refuses while a reader still holds the file open - bound the refresh request on its own, so a provider that goes quiet cannot hold the lock indefinitely - accept bare hostnames such as localhost, store URLs without the trailing slash, and join download paths through one helper
This commit is contained in:
@@ -8,6 +8,9 @@ from mdrsclient.api.base import BaseApi
|
||||
from mdrsclient.exceptions import UnauthorizedException
|
||||
from mdrsclient.models import Token, User
|
||||
|
||||
# (connect, read) seconds for the token refresh.
|
||||
TOKEN_REFRESH_TIMEOUT: Final[tuple[float, float]] = (5.0, 30.0)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UsersCurrentResponseLaboratory:
|
||||
@@ -61,7 +64,10 @@ class UsersApi(BaseApi):
|
||||
# print(self.__class__.__name__ + "::" + sys._getframe().f_code.co_name)
|
||||
url = self.ENTRYPOINT + "token/refresh/"
|
||||
data: dict[str, str | int] = {"refresh": token.refresh}
|
||||
response = self.connection.post(url, data=data)
|
||||
# Bounded on its own: the caller holds a lock that spans processes while this runs,
|
||||
# so a provider that accepts the connection and then goes quiet would otherwise
|
||||
# stall every other request on this machine rather than just this one.
|
||||
response = self.connection.post(url, data=data, timeout=TOKEN_REFRESH_TIMEOUT)
|
||||
if response.status_code == requests.codes.unauthorized:
|
||||
raise UnauthorizedException("Token is invalid or expired.")
|
||||
self._raise_response_error(response)
|
||||
|
||||
+31
-12
@@ -4,16 +4,35 @@ from mdrsclient.exceptions import UnauthorizedException
|
||||
|
||||
|
||||
def token_check(connection: MDRSConnection) -> None:
|
||||
try:
|
||||
connection.lock.acquire()
|
||||
if connection.token is not None:
|
||||
if connection.token.is_refresh_required:
|
||||
user_api = UsersApi(connection)
|
||||
try:
|
||||
connection.token = user_api.tokenRefresh(connection.token)
|
||||
except UnauthorizedException:
|
||||
connection.logout()
|
||||
elif connection.token.is_expired:
|
||||
"""
|
||||
Bring the access token up to date before a request goes out.
|
||||
|
||||
Refreshing is a read-modify-write over a cache shared with every other client
|
||||
process on this machine, and a rotating provider stops honouring the refresh token
|
||||
it replaces. Two processes reaching this at once would otherwise both send the same
|
||||
token, and the loser would be left holding one the server no longer accepts, so the
|
||||
whole sequence runs under a lock that spans processes and the cache is re-read
|
||||
inside it.
|
||||
"""
|
||||
with connection.lock:
|
||||
token = connection.token
|
||||
if token is None or not (token.is_refresh_required or token.is_expired):
|
||||
# Nothing to do, which is the answer for almost every request. The lock below
|
||||
# reaches across processes and is held for a round trip, so it is worth
|
||||
# knowing that before taking it.
|
||||
return
|
||||
with connection.cache_lock():
|
||||
connection.reload_cache()
|
||||
token = connection.token
|
||||
if token is None:
|
||||
return
|
||||
if token.is_expired:
|
||||
connection.logout()
|
||||
return
|
||||
if not token.is_refresh_required:
|
||||
return
|
||||
user_api = UsersApi(connection)
|
||||
try:
|
||||
connection.token = user_api.tokenRefresh(token)
|
||||
except UnauthorizedException:
|
||||
connection.logout()
|
||||
finally:
|
||||
connection.lock.release()
|
||||
|
||||
Reference in New Issue
Block a user