Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,50 @@ We provide `Examples`_ to help get you started with a lot of the basic functiona
- `Reliable file transfer <https://github.com/dropbox/dropbox-sdk-python/blob/main/example/file_transfer.py>`_ - Downloads and uploads files with retry, progress, and content validation helpers.
- `Backup and Restore <https://github.com/dropbox/dropbox-sdk-python/tree/main/example/back-up-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
=======================

Expand Down
1 change: 1 addition & 0 deletions dropbox/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
68 changes: 56 additions & 12 deletions dropbox/dropbox_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__
Expand Down Expand Up @@ -61,6 +63,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=<redacted>, expires_at={!r})".format(
self.expires_at
)


class RouteResult(object):
"""The successful result of a call to a route."""

Expand Down Expand Up @@ -202,6 +219,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
Expand Down Expand Up @@ -267,8 +285,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,
self._max_retries_on_error if max_retries_on_error is None else max_retries_on_error,
(
self._max_retries_on_rate_limit
Expand All @@ -280,7 +302,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,
Expand Down Expand Up @@ -403,18 +425,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)

Expand Down Expand Up @@ -471,10 +511,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,
Expand Down Expand Up @@ -880,10 +921,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,
Expand Down
Loading
Loading