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:
+27
-4
@@ -10,6 +10,31 @@ from mdrsclient.settings import CONFIG_DIRNAME
|
||||
from mdrsclient.utils import FileLock
|
||||
|
||||
|
||||
def normalize_url(url: str) -> str:
|
||||
"""
|
||||
Check a remote URL and put it in the one form every client agrees on.
|
||||
|
||||
`simple_host` is what lets a bare hostname through, so a development server on
|
||||
`localhost` is as acceptable as a deployment behind a domain name. The trailing
|
||||
slash goes because the URL is joined with a path that brings its own.
|
||||
"""
|
||||
if not validators.url(url, simple_host=True, validate_scheme=lambda scheme: scheme in ("http", "https")):
|
||||
raise IllegalArgumentException("malformed URI sequence")
|
||||
return url.rstrip("/")
|
||||
|
||||
|
||||
def build_download_url(base_url: str | None, path: str) -> str:
|
||||
"""
|
||||
Join a remote base URL with a path the API returned.
|
||||
|
||||
The API answers with a relative path and no leading separator, and a configuration
|
||||
written before the URL was normalised may still carry a trailing one.
|
||||
"""
|
||||
if path.startswith(("http://", "https://")):
|
||||
return path
|
||||
return f"{(base_url or '').rstrip('/')}/{path.lstrip('/')}"
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ConfigInterface(Protocol):
|
||||
remote: str
|
||||
@@ -42,8 +67,7 @@ class InMemoryConfig(ConfigInterface):
|
||||
|
||||
@url.setter
|
||||
def url(self, url: str) -> None:
|
||||
if not validators.url(url):
|
||||
raise IllegalArgumentException("malformed URI sequence")
|
||||
url = normalize_url(url)
|
||||
with self.__lock:
|
||||
self.__configs[self.remote] = url
|
||||
|
||||
@@ -91,8 +115,7 @@ class ConfigFile(ConfigInterface):
|
||||
|
||||
@url.setter
|
||||
def url(self, url: str) -> None:
|
||||
if not validators.url(url):
|
||||
raise IllegalArgumentException("malformed URI sequence")
|
||||
url = normalize_url(url)
|
||||
self.__load()
|
||||
if self.__config.has_section(self.remote):
|
||||
self.__config.remove_section(self.remote)
|
||||
|
||||
Reference in New Issue
Block a user