From 00422cdbe08b127657f476f787d20a79f919a61c Mon Sep 17 00:00:00 2001 From: Andrey Markelov Date: Thu, 12 Nov 2026 17:06:50 -0800 Subject: [PATCH] Expose cached OAuth2 access token snapshots --- README.rst | 44 ++++ dropbox/__init__.py | 1 + dropbox/dropbox_client.py | 68 +++++- test/unit/test_access_token_snapshot.py | 302 ++++++++++++++++++++++++ 4 files changed, 403 insertions(+), 12 deletions(-) create mode 100644 test/unit/test_access_token_snapshot.py diff --git a/README.rst b/README.rst index 48a022d..bc20ca1 100644 --- a/README.rst +++ b/README.rst @@ -60,6 +60,50 @@ We provide `Examples`_ to help get you started with a lot of the basic functiona - `Reliable file transfer `_ - Downloads and uploads files with retry, progress, and content validation helpers. - `Backup and Restore `_ - Sample application that shows how you can backup a file and restore previous versions if the file was modified/corrupted in any way. +Reusing Cached Access Tokens +=========================== + +``Dropbox.get_access_token_snapshot()`` and +``DropboxTeam.get_access_token_snapshot()`` read local cached credentials without +HTTP requests or refreshing. They return an immutable ``AccessTokenSnapshot`` +with ``access_token`` and ``expires_at``, or ``None`` if no access token is cached. +Expiration is a ``datetime`` using the constructor's expiration convention +(naive UTC for SDK refreshes), or ``None`` when unknown. + +.. code-block:: python + + import dropbox + + # Load refresh credentials separately from your application's secure storage. + dbx = dropbox.Dropbox( + oauth2_refresh_token=refresh_token, + app_key=app_key, + app_secret=app_secret, + ) + dbx.users_get_current_account() # Automatically refreshes when needed. + snapshot = dbx.get_access_token_snapshot() + if snapshot is not None: + # Persist snapshot.access_token and snapshot.expires_at securely. + next_dbx = dropbox.Dropbox( + oauth2_access_token=snapshot.access_token, + oauth2_access_token_expiration=snapshot.expires_at, + oauth2_refresh_token=refresh_token, + app_key=app_key, + app_secret=app_secret, + ) + next_dbx.users_get_current_account() + +An explicit ``dbx.refresh_access_token()`` also updates the cache. Reading a +snapshot never refreshes credentials: tokens may be expired or revoked, and an +unknown expiration does not imply unlimited lifetime. Store access tokens securely +and never log them. Snapshots never include refresh tokens. + +``clone()``, ``with_path_root()``, ``as_user()``, and ``as_admin()`` copy the cached +credentials at creation. Each client subsequently refreshes independently; +unlike the .NET SDK, these clients do not share mutable credential state. +Concurrent refreshes remain independent requests; each successful completion +publishes a complete token/expiration pair, and failures preserve the cache. + Reliable File Transfers ======================= diff --git a/dropbox/__init__.py b/dropbox/__init__.py index 691e155..f7ef54b 100644 --- a/dropbox/__init__.py +++ b/dropbox/__init__.py @@ -3,6 +3,7 @@ from ._http import build_range_headers as build_range_headers from dropbox.dropbox_client import ( # noqa: F401 # pylint: disable=unused-import __version__, + AccessTokenSnapshot, Dropbox, DropboxTeam, create_session, diff --git a/dropbox/dropbox_client.py b/dropbox/dropbox_client.py index 8d7cf1f..ba95937 100644 --- a/dropbox/dropbox_client.py +++ b/dropbox/dropbox_client.py @@ -11,7 +11,9 @@ import logging import random import time +from collections import namedtuple from datetime import datetime, timedelta +from threading import Lock import requests from dropbox._version import __version__ @@ -60,6 +62,21 @@ NO_AUTH = "noauth" +class AccessTokenSnapshot(namedtuple("AccessTokenSnapshot", "access_token expires_at")): + """Immutable cached access token and expiration, without refresh credentials. + + ``expires_at`` is a datetime in the constructor's expiration convention + (naive UTC for SDK refreshes), or None if unknown. + """ + + __slots__ = () + + def __repr__(self): + return "AccessTokenSnapshot(access_token=, expires_at={!r})".format( + self.expires_at + ) + + class RouteResult(object): """The successful result of a call to a route.""" @@ -201,6 +218,7 @@ def __init__( if scope is not None and (not isinstance(scope, list) or len(scope) == 0): raise BadInputException("Scope list must be of type list") + self._access_token_lock = Lock() self._oauth2_access_token = oauth2_access_token self._oauth2_refresh_token = oauth2_refresh_token self._oauth2_access_token_expiration = oauth2_access_token_expiration @@ -266,8 +284,12 @@ def clone( :rtype: Dropbox """ + with self._access_token_lock: + cached_token = self._oauth2_access_token + cached_expiration = self._oauth2_access_token_expiration + return self.__class__( - oauth2_access_token or self._oauth2_access_token, + oauth2_access_token or cached_token, max_retries_on_error or self._max_retries_on_error, max_retries_on_rate_limit or self._max_retries_on_rate_limit, user_agent or self._raw_user_agent, @@ -275,7 +297,7 @@ def clone( headers or self._headers, timeout or self._timeout, oauth2_refresh_token or self._oauth2_refresh_token, - oauth2_access_token_expiration or self._oauth2_access_token_expiration, + oauth2_access_token_expiration or cached_expiration, app_key or self._app_key, app_secret or self._app_secret, scope or self._scope, @@ -398,18 +420,36 @@ def _maybe_add_content_hash(self, request_arg, request_binary): request_arg.content_hash = _content_hash(request_binary) return request_arg + def get_access_token_snapshot(self): + """Read cached credentials without HTTP requests or token refresh. + + The snapshot may contain an expired or revoked token. Unknown expiration + does not imply unlimited lifetime. Store access tokens securely. + + :return: An immutable snapshot, or None when no access token is cached. + :rtype: AccessTokenSnapshot or None + """ + with self._access_token_lock: + if not self._oauth2_access_token: + return None + return AccessTokenSnapshot( + self._oauth2_access_token, self._oauth2_access_token_expiration + ) + def check_and_refresh_access_token(self): """ Checks if access token needs to be refreshed and refreshes if possible :return: """ + with self._access_token_lock: + access_token = self._oauth2_access_token + expiration = self._oauth2_access_token_expiration can_refresh = self._oauth2_refresh_token and self._app_key needs_refresh = self._oauth2_refresh_token and ( - not self._oauth2_access_token_expiration - or (datetime.utcnow() + timedelta(seconds=TOKEN_EXPIRATION_BUFFER)) - >= self._oauth2_access_token_expiration + not expiration + or (datetime.utcnow() + timedelta(seconds=TOKEN_EXPIRATION_BUFFER)) >= expiration ) - needs_token = not self._oauth2_access_token + needs_token = not access_token if (needs_refresh or needs_token) and can_refresh: self.refresh_access_token(scope=self._scope) @@ -470,10 +510,11 @@ def refresh_access_token(self, host=API_HOST, scope=None): raise token_content = res.json() - self._oauth2_access_token = token_content["access_token"] - self._oauth2_access_token_expiration = datetime.utcnow() + timedelta( - seconds=int(token_content["expires_in"]) - ) + access_token = token_content["access_token"] + expiration = datetime.utcnow() + timedelta(seconds=int(token_content["expires_in"])) + with self._access_token_lock: + self._oauth2_access_token = access_token + self._oauth2_access_token_expiration = expiration def request_json_object( self, @@ -868,10 +909,13 @@ def _get_dropbox_client_with_select_header(self, select_header_name, team_member new_headers = self._headers.copy() if self._headers else {} new_headers[select_header_name] = team_member_id + with self._access_token_lock: + access_token = self._oauth2_access_token + expiration = self._oauth2_access_token_expiration return Dropbox( - oauth2_access_token=self._oauth2_access_token, + oauth2_access_token=access_token, oauth2_refresh_token=self._oauth2_refresh_token, - oauth2_access_token_expiration=self._oauth2_access_token_expiration, + oauth2_access_token_expiration=expiration, max_retries_on_error=self._max_retries_on_error, max_retries_on_rate_limit=self._max_retries_on_rate_limit, timeout=self._timeout, diff --git a/test/unit/test_access_token_snapshot.py b/test/unit/test_access_token_snapshot.py new file mode 100644 index 0000000..a78c279 --- /dev/null +++ b/test/unit/test_access_token_snapshot.py @@ -0,0 +1,302 @@ +"""Cached credential tests: no Dropbox credentials or real HTTP requests.""" + +import json +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime, timedelta +from threading import Event, Lock +from unittest.mock import patch + +import pytest +import requests + +from dropbox import AccessTokenSnapshot, Dropbox, DropboxTeam +from dropbox.common import PathRoot +from dropbox.exceptions import AuthError + + +NOW = datetime(2030, 1, 2, 3, 4, 5) +OLD_EXPIRATION = NOW - timedelta(hours=1) +NEW_EXPIRATION = NOW + timedelta(hours=1) + + +def response(body, status=200): + result = requests.Response() + result.status_code = status + result.headers["Content-Type"] = "application/json" + result._content = json.dumps(body).encode("utf-8") + return result + + +@pytest.fixture(autouse=True) +def fixed_time(): + with patch("dropbox.dropbox_client.datetime") as clock: + clock.utcnow.return_value = NOW + yield + + +@pytest.fixture +def http(): + # Patch below Session.post so unexpected requests through any HTTP method fail. + with patch.object( + requests.Session, "request", side_effect=AssertionError("Unexpected HTTP") + ) as req: + yield req + + +@pytest.fixture(params=[Dropbox, DropboxTeam]) +def client_type(request): + return request.param + + +def make_client(client_type, token="old-token", expiration=OLD_EXPIRATION): + return client_type( + oauth2_access_token=token, + oauth2_access_token_expiration=expiration, + oauth2_refresh_token="refresh-secret", + app_key="app-key", + ) + + +@pytest.mark.parametrize("token", [None, ""]) +def test_no_cached_token(client_type, http, token): + client = make_client(client_type, token) + assert client.get_access_token_snapshot() is None + http.assert_not_called() + + +@pytest.mark.parametrize("expiration", [None, OLD_EXPIRATION, NEW_EXPIRATION]) +def test_cached_snapshot_never_refreshes(client_type, http, expiration): + client = make_client(client_type, expiration=expiration) + with ( + patch.object(client, "check_and_refresh_access_token") as check, + patch.object(client, "refresh_access_token") as refresh, + ): + snapshot = client.get_access_token_snapshot() + assert isinstance(snapshot, AccessTokenSnapshot) + assert snapshot.access_token == "old-token" + assert snapshot.expires_at == expiration + assert snapshot._fields == ("access_token", "expires_at") + assert not hasattr(snapshot, "refresh_token") + assert "old-token" not in repr(snapshot) + assert "refresh-secret" not in repr(snapshot) + check.assert_not_called() + refresh.assert_not_called() + http.assert_not_called() + + +def test_app_credentials_without_token(client_type, http): + assert client_type(app_key="key", app_secret="secret").get_access_token_snapshot() is None + http.assert_not_called() + + +def test_snapshot_is_immutable(client_type, http): + snapshot = make_client(client_type).get_access_token_snapshot() + for name in ("access_token", "expires_at", "extra"): + with pytest.raises(AttributeError): + setattr(snapshot, name, "replacement") + with pytest.raises(TypeError): + snapshot[0] = "replacement" + http.assert_not_called() + + +def test_explicit_refresh_keeps_old_snapshot(client_type, http): + client = make_client(client_type) + before = client.get_access_token_snapshot() + http.side_effect = None + http.return_value = response({"access_token": "new-token", "expires_in": 3600}) + assert client.refresh_access_token() is None # Preserve the existing return value. + assert client.get_access_token_snapshot() == AccessTokenSnapshot("new-token", NEW_EXPIRATION) + assert before == AccessTokenSnapshot("old-token", OLD_EXPIRATION) + assert http.call_count == 1 + + +@pytest.mark.parametrize("token,expiration", [(None, None), ("old", None), ("old", OLD_EXPIRATION)]) +def test_automatic_refresh_and_reuse(client_type, http, token, expiration): + client = make_client(client_type, token, expiration) + + def handle(method, url, **kwargs): + assert method == "POST" + if url.endswith("/oauth2/token"): + return response({"access_token": "new-token", "expires_in": 3600}) + assert kwargs["headers"]["Authorization"] == "Bearer new-token" + if url.endswith("/team/members/list"): + return response({"members": [], "has_more": False, "cursor": "cursor"}) + return response({"result": "ok"}) + + def api_request(dbx): + if isinstance(dbx, DropboxTeam): + assert dbx.team_members_list().members == [] + else: + assert dbx.check_user().result == "ok" + + http.side_effect = handle + # A normal generated API call exercises request() and automatic refresh. + api_request(client) + snapshot = client.get_access_token_snapshot() + assert snapshot == AccessTokenSnapshot("new-token", NEW_EXPIRATION) + reused = client_type( + oauth2_access_token=snapshot.access_token, + oauth2_access_token_expiration=snapshot.expires_at, + oauth2_refresh_token="refresh-secret", + app_key="app-key", + ) + api_request(reused) + assert reused.get_access_token_snapshot() == snapshot + assert http.call_count == 3 # One refresh, two API calls. + assert sum(call.args[1].endswith("/oauth2/token") for call in http.call_args_list) == 1 + + +@pytest.mark.parametrize("failure", ["http", "network", "json", "missing", "expiration"]) +def test_failed_refresh_preserves_pair(client_type, http, failure): + client = make_client(client_type) + before = client.get_access_token_snapshot() + http.side_effect = None + if failure == "http": + http.return_value = response({"error": "invalid_grant"}, 400) + error = AuthError + elif failure == "network": + http.side_effect = requests.ConnectionError("offline") + error = requests.ConnectionError + elif failure == "json": + http.return_value = response({}) + http.return_value._content = b"invalid json" + error = ValueError + elif failure == "missing": + http.return_value = response({"access_token": "partial-token"}) + error = KeyError + else: + http.return_value = response({"access_token": "partial-token", "expires_in": "invalid"}) + error = ValueError + with pytest.raises(error): + client.refresh_access_token() + assert client.get_access_token_snapshot() == before + assert http.call_count == 1 + + +def test_refresh_without_refresh_credentials_preserves_snapshot(client_type, http): + client = client_type("cached-token") + before = client.get_access_token_snapshot() + assert client.refresh_access_token() is None + assert client.get_access_token_snapshot() == before + http.assert_not_called() + + +@pytest.mark.parametrize("factory", ["clone", "with_path_root", "as_user", "as_admin"]) +@pytest.mark.parametrize("token", [None, "old-token"]) +def test_clones_copy_credentials_and_refresh_independently(http, factory, token): + client = make_client(DropboxTeam, token) + if factory == "clone": + child = client.clone() + elif factory == "with_path_root": + child = client.with_path_root(PathRoot.home) + else: + child = getattr(client, factory)("member-id") + before = client.get_access_token_snapshot() + assert child.get_access_token_snapshot() == before + assert type(child) is (Dropbox if factory in ("as_user", "as_admin") else DropboxTeam) + assert child._session is client._session + http.assert_not_called() + http.side_effect = None + http.return_value = response({"access_token": "child-token", "expires_in": 3600}) + child.refresh_access_token() + assert client.get_access_token_snapshot() == before + child_snapshot = child.get_access_token_snapshot() + assert child_snapshot == AccessTokenSnapshot("child-token", NEW_EXPIRATION) + http.return_value = response({"access_token": "parent-token", "expires_in": 7200}) + client.refresh_access_token() + assert child.get_access_token_snapshot() == child_snapshot + + +def test_clone_overrides_preserve_existing_fallbacks(client_type, http): + client = make_client(client_type) + assert client.clone().get_access_token_snapshot() == client.get_access_token_snapshot() + assert client.with_path_root(PathRoot.home).get_access_token_snapshot() == ( + client.get_access_token_snapshot() + ) + assert client.clone(oauth2_access_token="override").get_access_token_snapshot() == ( + AccessTokenSnapshot("override", OLD_EXPIRATION) + ) + assert client.clone( + oauth2_access_token_expiration=NEW_EXPIRATION + ).get_access_token_snapshot() == (AccessTokenSnapshot("old-token", NEW_EXPIRATION)) + http.assert_not_called() + + +def test_concurrent_refreshes_and_reads(client_type, http): + client = make_client(client_type) + original = client.get_access_token_snapshot() + entered = [Event(), Event()] + release = [Event(), Event()] + + def handle(method, url, **kwargs): + index = int(kwargs["data"]["scope"]) + entered[index].set() + assert release[index].wait(10), "Test did not release mocked HTTP request" + return response( + {"access_token": "token-{}".format(index), "expires_in": (index + 1) * 3600} + ) + + http.side_effect = handle + with ThreadPoolExecutor(max_workers=3) as executor: + first = executor.submit(client.refresh_access_token, scope=["0"]) + second = executor.submit(client.refresh_access_token, scope=["1"]) + try: + assert all(event.wait(10) for event in entered) + # Reads must finish while both HTTP requests remain blocked. + assert executor.submit(client.get_access_token_snapshot).result(10) == original + release[1].set() + second.result(10) + second_snapshot = client.get_access_token_snapshot() + assert second_snapshot == AccessTokenSnapshot("token-1", NOW + timedelta(hours=2)) + release[0].set() + first.result(10) + assert client.get_access_token_snapshot() == AccessTokenSnapshot( + "token-0", NEW_EXPIRATION + ) + assert second_snapshot.access_token == "token-1" + assert original == AccessTokenSnapshot("old-token", OLD_EXPIRATION) + finally: + for event in release: + event.set() + assert http.call_count == 2 + + +def test_snapshot_waits_for_complete_publication(client_type, http): + token_written = Event() + finish_publication = Event() + reader_entered = Event() + + class ObservedLock: + def __init__(self): + self.lock = Lock() + + def __enter__(self): + reader_entered.set() + self.lock.acquire() + + def __exit__(self, *args): + self.lock.release() + + class PausingClient(client_type): + def __setattr__(self, name, value): + super().__setattr__(name, value) + if name == "_oauth2_access_token" and value == "new-token": + token_written.set() + assert finish_publication.wait(10), "Test did not release publication" + + client = make_client(PausingClient) + client._access_token_lock = ObservedLock() + http.side_effect = None + http.return_value = response({"access_token": "new-token", "expires_in": 3600}) + with ThreadPoolExecutor(max_workers=2) as executor: + refresh = executor.submit(client.refresh_access_token) + try: + # Pause the real refresh between its two field assignments. + assert token_written.wait(10) + reader_entered.clear() + read = executor.submit(client.get_access_token_snapshot) + assert reader_entered.wait(10) + finally: + finish_publication.set() + refresh.result(10) + assert read.result(10) == AccessTokenSnapshot("new-token", NEW_EXPIRATION)