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:
@@ -0,0 +1,116 @@
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
import jwt
|
||||
|
||||
from mdrsclient.api.utils import token_check
|
||||
from mdrsclient.cache import CacheFile
|
||||
from mdrsclient.config import normalize_url
|
||||
from mdrsclient.connection import MDRSConnection
|
||||
from mdrsclient.exceptions import IllegalArgumentException
|
||||
from mdrsclient.models import Token
|
||||
|
||||
REMOTE = "unittest"
|
||||
|
||||
|
||||
def make_token(access_offset: int, refresh_offset: int, label: str) -> Token:
|
||||
now = int(time.time())
|
||||
|
||||
def encode(token_type: str, offset: int) -> str:
|
||||
return jwt.encode(
|
||||
{
|
||||
"token_type": token_type,
|
||||
"exp": now + offset,
|
||||
"iat": now,
|
||||
"jti": f"{label}-{token_type}",
|
||||
"user_id": 1,
|
||||
},
|
||||
"unittest-signing-key-not-verified-anywhere",
|
||||
)
|
||||
|
||||
return Token(access=encode("access", access_offset), refresh=encode("refresh", refresh_offset))
|
||||
|
||||
|
||||
class TestTokenCache(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
patcher = patch("mdrsclient.cache.CONFIG_DIRNAME", self.tmp.name)
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
|
||||
def test_reload_picks_up_a_write_from_another_holder(self) -> None:
|
||||
"""The cache is shared, so a value read earlier can already be out of date."""
|
||||
reader = CacheFile(REMOTE)
|
||||
writer = CacheFile(REMOTE)
|
||||
|
||||
first = make_token(3600, 86400, "first")
|
||||
writer.token = first
|
||||
self.assertEqual(reader.token, first)
|
||||
|
||||
second = make_token(3600, 86400, "second")
|
||||
writer.token = second
|
||||
|
||||
reader.reload()
|
||||
self.assertEqual(reader.token, second)
|
||||
|
||||
def test_token_check_uses_the_token_another_holder_just_wrote(self) -> None:
|
||||
"""A rotating provider drops the token it replaces, so the refresh must not
|
||||
be sent again once someone else has already made the round trip."""
|
||||
connection = MDRSConnection(REMOTE, "http://localhost:8000/api/")
|
||||
connection.token = make_token(-60, 86400, "stale")
|
||||
|
||||
# Another process refreshes while this one is between requests.
|
||||
other = CacheFile(REMOTE)
|
||||
rotated = make_token(3600, 86400, "rotated")
|
||||
other.token = rotated
|
||||
|
||||
with patch("mdrsclient.api.utils.UsersApi") as users_api:
|
||||
token_check(connection)
|
||||
|
||||
users_api.assert_not_called()
|
||||
self.assertEqual(connection.token, rotated)
|
||||
|
||||
def test_token_check_refreshes_when_nothing_else_has(self) -> None:
|
||||
connection = MDRSConnection(REMOTE, "http://localhost:8000/api/")
|
||||
connection.token = make_token(-60, 86400, "stale")
|
||||
rotated = make_token(3600, 86400, "rotated")
|
||||
|
||||
with patch("mdrsclient.api.utils.UsersApi") as users_api:
|
||||
users_api.return_value.tokenRefresh.return_value = rotated
|
||||
token_check(connection)
|
||||
|
||||
users_api.return_value.tokenRefresh.assert_called_once()
|
||||
self.assertEqual(connection.token, rotated)
|
||||
|
||||
def test_token_check_logs_out_once_the_refresh_token_expires(self) -> None:
|
||||
connection = MDRSConnection(REMOTE, "http://localhost:8000/api/")
|
||||
connection.token = make_token(-3600, -60, "dead")
|
||||
|
||||
with patch("mdrsclient.api.utils.UsersApi") as users_api:
|
||||
token_check(connection)
|
||||
|
||||
users_api.assert_not_called()
|
||||
self.assertIsNone(connection.token)
|
||||
|
||||
|
||||
class TestUrlNormalization(unittest.TestCase):
|
||||
"""Both clients share config.ini, so they have to agree on what a remote URL is."""
|
||||
|
||||
def test_trailing_slash_is_dropped(self) -> None:
|
||||
self.assertEqual(normalize_url("http://127.0.0.1:8000/api/"), "http://127.0.0.1:8000/api")
|
||||
self.assertEqual(normalize_url("https://neurodata.riken.jp/api/"), "https://neurodata.riken.jp/api")
|
||||
|
||||
def test_bare_hostname_is_accepted(self) -> None:
|
||||
self.assertEqual(normalize_url("http://localhost:8000/api"), "http://localhost:8000/api")
|
||||
|
||||
def test_only_http_schemes_are_accepted(self) -> None:
|
||||
for url in ("ftp://x.example.com/", "file:///etc/passwd", "not-a-url", "http://"):
|
||||
with self.subTest(url=url), self.assertRaises(IllegalArgumentException):
|
||||
normalize_url(url)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user