deer-flow/backend/tests/test_mcp_oauth_token_store.py
Nan Gao f39cdd6077
fix(mcp): keep rotating OAuth refresh tokens across restarts and Gateway instances (#7207)
* fix(mcp): keep rotating OAuth refresh tokens across restarts and Gateway instances

MCP servers on the OAuth refresh_token grant lost their rotated refresh
token whenever a new process or manager started (restart, tool-cache
reset for non-task servers, every personal-connection run, a second
Gateway instance) and re-seeded with the consumed configured token,
getting invalid_grant for good.

- New table mcp_oauth_tokens (migration 0047_mcp_oauth_tokens), one row
  per refresh_token connection keyed (scope, owner_user_id, server_key):
  ('deployment', '', name) or ('personal', owner, personal runtime
  name). Access and refresh tokens are DEER_FLOW_CREDENTIALS_KEY
  ciphertext; a config fingerprint (token_url, client_id, grant, scope,
  audience, seed) makes a changed seed win.
- OAuthTokenManager re-reads the row under its process lock, adopts a
  peer's valid token, and otherwise claims the refresh with a lease plus
  a version compare-and-set (database clock), fetches outside any
  transaction, writes under the lease and releases it on failure.
- The Gateway installs the store (sync engine via to_thread) around the
  LangGraph runtime for sqlite/postgres; personal managers pass their
  owner. client_credentials stays in process; an unloadable key falls
  back to process memory; database errors fail the refresh.
- credentials_key_consumers lists refresh_token MCP OAuth servers, so a
  declared multi-instance deployment needs the shared key.

Docs: MCP_SERVER.md, CONFIGURATION.md, Helm README, README, mcp and
migrations AGENTS.md, CHANGELOG (en/zh).

* docs(changelog): link the MCP OAuth token store entry to #7207

* fix(mcp): prune removed connections' OAuth token rows and ask owners to re-authorize on invalid_grant

Review follow-up (willem-bd):
- Both MCP config writers drop mcp_oauth_tokens rows of connections a
  save removes (deployment: _commit_mcp_config_write; personal:
  _mutate, keeping each connection's enabled/disabled runtime-name
  variants so a toggle keeps its chain). Best effort; no startup sweep,
  since a missing config file would look like every server removed.
- invalid_grant on a refresh_token request raises
  McpOAuthReauthorizationRequired: the tool error names the server and
  asks for a new refresh token, plus the shared-key hint when this
  instance could not decrypt the stored chain.
- Annotate the gateway helpers' config as AppConfig.

* fix(mcp): renew the OAuth refresh lease, keep unstored rotations, and key access tokens by request parameters

Review follow-up (willem-bd, second round):
- HTTPX timeouts bound each I/O wait, not a whole response: the refresh
  holder now renews its lease every third of REFRESH_LEASE_SECONDS while
  the token request runs and abandons it after REFRESH_MAX_SECONDS (120 s);
  waiters outlast a live holder. Regression uses a real loopback endpoint
  that streams its body for three lease lengths.
- A refresh whose tokens could not be stored keeps them pending
  (unsaved_base_version): they are written back before an older stored
  token is adopted or refreshed with, retried every few seconds while the
  access token is still valid, and dropped once a peer stored a newer
  chain. A failed save releases the lease.
- New token_request_fingerprint (extra_token_params and response fields):
  a stored access token is reused only under the same request parameters;
  otherwise the stored refresh-token chain is refreshed.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-21 18:17:29 +08:00

676 lines
33 KiB
Python

"""MCP OAuth tokens shared through ``mcp_oauth_tokens`` (restarts and Gateway replicas).
A ``refresh_token`` connection's access token and rotated refresh token live in
one encrypted row per connection. Every manager below stands for a separate
Gateway process: its own ``McpOAuthTokenStore`` and in-process token state over
one shared database. The token endpoint rotates on every refresh and rejects a
consumed refresh token with ``invalid_grant``, like Auth0/Okta/Google, so any
replica or restart that falls back to an already-used seed fails loudly.
"""
from __future__ import annotations
import asyncio
import os
import threading
import uuid
from concurrent.futures import ThreadPoolExecutor
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
from urllib.parse import parse_qs
import httpx
import pytest
import sqlalchemy as sa
from cryptography.fernet import Fernet
from sqlalchemy.engine import make_url
from sqlalchemy.orm import Session, sessionmaker
from deerflow.config.credentials_key import CredentialsCipher
from deerflow.config.extensions_config import ExtensionsConfig
from deerflow.mcp import oauth_store
from deerflow.mcp.oauth import McpOAuthReauthorizationRequired, OAuthTokenManager
from deerflow.mcp.oauth_store import (
McpOAuthTokenStore,
McpOAuthTokenStoreUnavailable,
StoredTokenLookup,
oauth_config_fingerprint,
oauth_token_request_fingerprint,
set_mcp_oauth_token_store,
)
from deerflow.persistence.base import Base
from deerflow.persistence.mcp_oauth_tokens import McpOAuthTokenRow, SqlMcpOAuthTokenRepository
from deerflow.persistence.postgres_schema import dsn_with_search_path
SERVER = "reports"
REQUEST = "r" * 64
class _Clock:
current = datetime(2026, 1, 1, tzinfo=UTC)
@classmethod
def now(cls, _tz):
return cls.current
class _RotatingTokenEndpoint:
"""Issue ``access-N``/``refresh-N`` and accept only the latest refresh token."""
def __init__(self) -> None:
self.refresh_token = "refresh-0"
self.generation = 0
self.refresh_requests: list[str] = []
self.forms: list[dict[str, list[str]]] = []
self.fail_with: int | None = None
def handle(self, request: httpx.Request) -> httpx.Response:
assert request.url.path == "/token"
form = parse_qs(request.content.decode())
submitted = form["refresh_token"][0]
self.refresh_requests.append(submitted)
self.forms.append(form)
if self.fail_with is not None:
return httpx.Response(self.fail_with, json={"error": "server_error"})
if submitted != self.refresh_token:
return httpx.Response(400, json={"error": "invalid_grant"})
self.generation += 1
self.refresh_token = f"refresh-{self.generation}"
return httpx.Response(200, json={"access_token": f"access-{self.generation}", "refresh_token": self.refresh_token, "expires_in": 120})
@pytest.fixture
def endpoint(monkeypatch):
server = _RotatingTokenEndpoint()
original_client = httpx.AsyncClient
class OfflineClient(original_client):
def __init__(self, *args, **kwargs):
kwargs["transport"] = httpx.MockTransport(lambda request: server.handle(request))
kwargs["trust_env"] = False
super().__init__(*args, **kwargs)
monkeypatch.setattr(httpx, "AsyncClient", OfflineClient)
monkeypatch.setattr("deerflow.mcp.oauth.datetime", _Clock)
monkeypatch.setattr(_Clock, "current", datetime(2026, 1, 1, tzinfo=UTC))
return server
@pytest.fixture(autouse=True)
def _no_installed_store():
previous = set_mcp_oauth_token_store(None)
yield
set_mcp_oauth_token_store(previous)
@pytest.fixture(params=["sqlite", "postgres"])
def session_factory(request, tmp_path) -> sessionmaker[Session]:
"""The synchronous session factory the Gateway builds from ``app_sync_sqlalchemy_url``, on each dialect."""
schema = None
if request.param == "sqlite":
engine = sa.create_engine(f"sqlite:///{(tmp_path / 'oauth.db').as_posix()}")
else:
uri = os.environ.get("TEST_POSTGRES_URI")
if not uri:
pytest.skip("requires TEST_POSTGRES_URI (real Postgres mcp_oauth_tokens)")
schema = f"mcp_oauth_tokens_{uuid.uuid4().hex}"
admin = sa.create_engine(make_url(uri).set(drivername="postgresql+psycopg"))
with admin.begin() as conn:
conn.execute(sa.text(f'CREATE SCHEMA "{schema}"'))
admin.dispose()
engine = sa.create_engine(make_url(dsn_with_search_path(uri, schema)).set(drivername="postgresql+psycopg"))
Base.metadata.create_all(engine, tables=[McpOAuthTokenRow.__table__])
yield sessionmaker(engine, expire_on_commit=False)
if schema is not None:
with engine.begin() as conn:
conn.execute(sa.text(f'DROP SCHEMA "{schema}" CASCADE'))
engine.dispose()
@pytest.fixture
def cipher() -> CredentialsCipher:
return CredentialsCipher.from_keys([Fernet.generate_key()])
def _config(*, seed: str = "refresh-0", grant_type: str = "refresh_token", name: str = SERVER, token_url: str = "https://auth.example.invalid/token", extra_token_params: dict[str, str] | None = None) -> ExtensionsConfig:
oauth = {"token_url": token_url, "grant_type": grant_type, "refresh_skew_seconds": 0, "extra_token_params": extra_token_params or {}}
if grant_type == "refresh_token":
oauth["refresh_token"] = seed
else:
oauth.update(client_id="client", client_secret="secret")
return ExtensionsConfig.model_validate({"mcpServers": {name: {"type": "http", "url": "https://mcp.example.invalid/mcp", "oauth": oauth}}})
def _replica(session_factory, cipher, config: ExtensionsConfig | None = None, *, owner_user_id: str | None = None) -> OAuthTokenManager:
"""One Gateway process: a fresh store and fresh in-process token state over the shared database."""
store = McpOAuthTokenStore(SqlMcpOAuthTokenRepository(session_factory), cipher=cipher)
return OAuthTokenManager.from_extensions_config(config or _config(), owner_user_id=owner_user_id, token_store=store)
def _rows(session_factory) -> list[McpOAuthTokenRow]:
with session_factory() as session:
return list(session.scalars(sa.select(McpOAuthTokenRow).order_by(McpOAuthTokenRow.scope, McpOAuthTokenRow.owner_user_id, McpOAuthTokenRow.server_key)))
def _expire_access_tokens() -> None:
_Clock.current = _Clock.current + timedelta(seconds=300)
@pytest.mark.asyncio
async def test_peer_uses_the_rotated_refresh_token_without_calling_the_token_endpoint(session_factory, cipher, endpoint):
a = _replica(session_factory, cipher)
b = _replica(session_factory, cipher)
assert await a.get_authorization_header(SERVER) == "Bearer access-1"
# B has never refreshed, but A's still-valid access token is in the row.
assert await b.get_authorization_header(SERVER) == "Bearer access-1"
assert endpoint.refresh_requests == ["refresh-0"]
_expire_access_tokens()
# B refreshes with A's rotated token, not the consumed seed ...
assert await b.get_authorization_header(SERVER) == "Bearer access-2"
# ... and A adopts B's result instead of refreshing with its stale copy.
assert await a.get_authorization_header(SERVER) == "Bearer access-2"
assert endpoint.refresh_requests == ["refresh-0", "refresh-1"]
@pytest.mark.asyncio
async def test_restart_does_not_reseed_from_the_consumed_seed(session_factory, cipher, endpoint):
assert await _replica(session_factory, cipher).get_authorization_header(SERVER) == "Bearer access-1"
_expire_access_tokens()
restarted = _replica(session_factory, cipher)
assert await restarted.get_authorization_header(SERVER) == "Bearer access-2"
assert endpoint.refresh_requests == ["refresh-0", "refresh-1"]
@pytest.mark.asyncio
async def test_changed_seed_wins_over_the_stored_rotation(session_factory, cipher, endpoint):
assert await _replica(session_factory, cipher).get_authorization_header(SERVER) == "Bearer access-1"
# The operator revokes the chain and configures a new seed.
endpoint.refresh_token = "replacement-seed"
changed = _config(seed="replacement-seed")
assert await _replica(session_factory, cipher, changed).get_authorization_header(SERVER) == "Bearer access-2"
_expire_access_tokens()
# The new chain is what the next process continues from.
assert await _replica(session_factory, cipher, changed).get_authorization_header(SERVER) == "Bearer access-3"
assert endpoint.refresh_requests == ["refresh-0", "replacement-seed", "refresh-2"]
(row,) = _rows(session_factory)
assert row.config_fingerprint == oauth_config_fingerprint(changed.mcp_servers[SERVER].oauth)
def test_concurrent_refresh_across_replicas_makes_one_token_request(session_factory, cipher, endpoint, monkeypatch):
a = _replica(session_factory, cipher)
b = _replica(session_factory, cipher)
a_fetching = threading.Event()
b_waiting = threading.Event()
release = threading.Event()
handle = endpoint.handle
async def held(request):
a_fetching.set()
assert await asyncio.to_thread(release.wait, 10), "test did not release the token response"
return handle(request)
monkeypatch.setattr(endpoint, "handle", held)
b_store = b._token_store
original_claim = b_store.claim
def observed_claim(*args, **kwargs):
claimed = original_claim(*args, **kwargs)
if claimed is None:
b_waiting.set()
return claimed
monkeypatch.setattr(b_store, "claim", observed_claim)
with ThreadPoolExecutor(max_workers=2) as executor:
first = executor.submit(lambda: asyncio.run(a.get_authorization_header(SERVER)))
try:
assert a_fetching.wait(10), "replica A never reached the token endpoint"
second = executor.submit(lambda: asyncio.run(b.get_authorization_header(SERVER)))
assert b_waiting.wait(10), "replica B did not find A's refresh lease"
finally:
release.set()
assert first.result(timeout=10) == "Bearer access-1"
assert second.result(timeout=10) == "Bearer access-1"
assert endpoint.refresh_requests == ["refresh-0"]
@pytest.mark.asyncio
async def test_tokens_are_encrypted_at_rest_and_unreadable_rows_fall_back_to_the_seed(session_factory, cipher, endpoint, caplog):
assert await _replica(session_factory, cipher).get_authorization_header(SERVER) == "Bearer access-1"
(row,) = _rows(session_factory)
assert (row.scope, row.owner_user_id, row.server_key, row.version) == ("deployment", "", SERVER, 1)
for column in (row.access_token_enc, row.refresh_token_enc):
assert column.startswith("fernet:v2:")
assert "access-1" not in column and "refresh-1" not in column
assert cipher.decrypt_text(row.access_token_enc) == "access-1"
assert cipher.decrypt_text(row.refresh_token_enc) == "refresh-1"
# A process with another key cannot read the row; it falls back to the
# seed (here already consumed) instead of failing on the ciphertext, and
# the rejection tells the connection's user why and who can fix it.
other = _replica(session_factory, CredentialsCipher.from_keys([Fernet.generate_key()]))
with caplog.at_level("WARNING", logger="deerflow.mcp.oauth_store"):
with pytest.raises(McpOAuthReauthorizationRequired) as rejected:
await other.get_authorization_header(SERVER)
assert endpoint.refresh_requests == ["refresh-0", "refresh-0"]
assert "cannot be decrypted" in caplog.text
assert "refresh-1" not in caplog.text
message = str(rejected.value)
assert f"'{SERVER}'" in message and "invalid_grant" in message and "DEER_FLOW_CREDENTIALS_KEY" in message
assert "refresh-0" not in message and "refresh-1" not in message
assert isinstance(rejected.value.__cause__, httpx.HTTPStatusError)
@pytest.mark.asyncio
async def test_personal_and_deployment_connections_with_one_name_keep_separate_rows(session_factory, cipher, endpoint):
assert await _replica(session_factory, cipher).get_authorization_header(SERVER) == "Bearer access-1"
endpoint.refresh_token = "personal-seed"
personal = _config(seed="personal-seed")
assert await _replica(session_factory, cipher, personal, owner_user_id="user-1").get_authorization_header(SERVER) == "Bearer access-2"
_expire_access_tokens()
# Each owner continues from its own chain after a restart.
assert await _replica(session_factory, cipher, personal, owner_user_id="user-1").get_authorization_header(SERVER) == "Bearer access-3"
assert [(row.scope, row.owner_user_id, row.server_key) for row in _rows(session_factory)] == [("deployment", "", SERVER), ("personal", "user-1", SERVER)]
assert endpoint.refresh_requests == ["refresh-0", "personal-seed", "refresh-2"]
@pytest.mark.asyncio
async def test_client_credentials_tokens_stay_in_process(session_factory, cipher, monkeypatch):
requests: list[str] = []
def handle(request: httpx.Request) -> httpx.Response:
requests.append(parse_qs(request.content.decode())["grant_type"][0])
return httpx.Response(200, json={"access_token": f"cc-{len(requests)}", "expires_in": 120})
original_client = httpx.AsyncClient
class OfflineClient(original_client):
def __init__(self, *args, **kwargs):
kwargs["transport"] = httpx.MockTransport(handle)
super().__init__(*args, **kwargs)
monkeypatch.setattr(httpx, "AsyncClient", OfflineClient)
config = _config(grant_type="client_credentials")
assert await _replica(session_factory, cipher, config).get_authorization_header(SERVER) == "Bearer cc-1"
assert await _replica(session_factory, cipher, config).get_authorization_header(SERVER) == "Bearer cc-2"
assert _rows(session_factory) == []
@pytest.mark.asyncio
async def test_failed_refresh_releases_the_lease_for_the_next_caller(session_factory, cipher, endpoint):
endpoint.fail_with = 503
with pytest.raises(httpx.HTTPStatusError):
await _replica(session_factory, cipher).get_authorization_header(SERVER)
(row,) = _rows(session_factory)
assert row.refresh_lease_owner is None
endpoint.fail_with = None
# The seed was never consumed, so a peer retries it at once, not after the lease runs out.
assert await asyncio.wait_for(_replica(session_factory, cipher).get_authorization_header(SERVER), 5) == "Bearer access-1"
assert endpoint.refresh_requests == ["refresh-0", "refresh-0"]
@pytest.mark.asyncio
async def test_an_abandoned_refresh_lease_is_taken_over_after_it_expires(session_factory, cipher, endpoint, monkeypatch):
monkeypatch.setattr(oauth_store, "REFRESH_LEASE_SECONDS", 0.5)
crashed = McpOAuthTokenStore(SqlMcpOAuthTokenRepository(session_factory), cipher=cipher)
key = oauth_store.McpOAuthTokenKey(scope="deployment", owner_user_id="", server_key=SERVER)
fingerprint = oauth_config_fingerprint(_config().mcp_servers[SERVER].oauth)
# A process claimed the first refresh and died before writing a token.
assert crashed.claim(key, fingerprint=fingerprint, expected_version=None) is not None
assert await asyncio.wait_for(_replica(session_factory, cipher).get_authorization_header(SERVER), 10) == "Bearer access-1"
(row,) = _rows(session_factory)
assert row.refresh_lease_owner is None
assert row.version == 1
@pytest.mark.asyncio
async def test_store_errors_fail_the_refresh_without_leaking_sql_parameters(cipher, endpoint, tmp_path, caplog):
engine = sa.create_engine(f"sqlite:///{(tmp_path / 'missing-table.db').as_posix()}")
manager = _replica(sessionmaker(engine), cipher)
try:
with caplog.at_level("ERROR"):
with pytest.raises(McpOAuthTokenStoreUnavailable) as raised:
await manager.get_authorization_header(SERVER)
finally:
engine.dispose()
assert SERVER in str(raised.value)
assert "mcp_oauth_tokens" not in str(raised.value)
# Never refresh without a place to put the rotated token.
assert endpoint.refresh_requests == []
@pytest.mark.asyncio
async def test_installed_store_is_used_by_managers_built_without_one(session_factory, cipher, endpoint):
set_mcp_oauth_token_store(McpOAuthTokenStore(SqlMcpOAuthTokenRepository(session_factory), cipher=cipher))
assert await OAuthTokenManager.from_extensions_config(_config()).get_authorization_header(SERVER) == "Bearer access-1"
_expire_access_tokens()
assert await OAuthTokenManager.from_extensions_config(_config()).get_authorization_header(SERVER) == "Bearer access-2"
assert endpoint.refresh_requests == ["refresh-0", "refresh-1"]
@pytest.mark.asyncio
async def test_without_a_store_tokens_stay_in_process(endpoint):
assert await OAuthTokenManager.from_extensions_config(_config()).get_authorization_header(SERVER) == "Bearer access-1"
_expire_access_tokens()
# Today's behaviour: a new manager starts again from the configured seed,
# and the rejection names the remedy instead of a bare HTTP 400.
with pytest.raises(McpOAuthReauthorizationRequired, match="configuring a new refresh token") as rejected:
await OAuthTokenManager.from_extensions_config(_config()).get_authorization_header(SERVER)
assert "DEER_FLOW_CREDENTIALS_KEY" not in str(rejected.value)
def test_fingerprint_follows_the_token_chain_identity_not_the_client_secret():
base = _config().mcp_servers[SERVER].oauth
fingerprint = oauth_config_fingerprint(base)
for field, value in {"token_url": "https://other.example.invalid/token", "client_id": "other", "scope": "read", "audience": "api", "refresh_token": "other-seed"}.items():
assert oauth_config_fingerprint(base.model_copy(update={field: value})) != fingerprint, field
# A rotated client secret keeps the issued refresh-token chain valid.
assert oauth_config_fingerprint(base.model_copy(update={"client_secret": "rotated"})) == fingerprint
assert "refresh-0" not in fingerprint
def test_a_claim_fails_once_a_peer_wrote_since_the_read(session_factory, cipher):
"""The version compare-and-set keeps a reader from refreshing with a refresh token a peer just consumed."""
key = oauth_store.McpOAuthTokenKey(scope="deployment", owner_user_id="", server_key=SERVER)
fingerprint = oauth_config_fingerprint(_config().mcp_servers[SERVER].oauth)
a = McpOAuthTokenStore(SqlMcpOAuthTokenRepository(session_factory), cipher=cipher)
b = McpOAuthTokenStore(SqlMcpOAuthTokenRepository(session_factory), cipher=cipher)
expires_at = datetime(2030, 1, 1, tzinfo=UTC)
lease = a.claim(key, fingerprint=fingerprint, expected_version=None)
assert a.save(key, lease_owner=lease, fingerprint=fingerprint, request_fingerprint=REQUEST, access_token="access-1", token_type="Bearer", expires_at=expires_at, refresh_token="refresh-1", rotated=True)
assert b.load(key, fingerprint=fingerprint, request_fingerprint=REQUEST)[0] == 1
# Both read version 1; A refreshes first.
lease = a.claim(key, fingerprint=fingerprint, expected_version=1)
assert lease is not None
assert b.claim(key, fingerprint=fingerprint, expected_version=1) is None, "a live lease must block the peer"
assert a.save(key, lease_owner=lease, fingerprint=fingerprint, request_fingerprint=REQUEST, access_token="access-2", token_type="Bearer", expires_at=expires_at, refresh_token="refresh-2", rotated=True)
assert b.claim(key, fingerprint=fingerprint, expected_version=1) is None, "the lease is free, but the row moved on"
version, stored, _unreadable = b.load(key, fingerprint=fingerprint, request_fingerprint=REQUEST)
assert (version, stored.refresh_token, stored.access_token) == (2, "refresh-2", "access-2")
# A write under a lease that is no longer held is refused.
assert not b.save(key, lease_owner="not-the-holder", fingerprint=fingerprint, request_fingerprint=REQUEST, access_token="x", token_type="Bearer", expires_at=expires_at, refresh_token="y", rotated=True)
assert b.load(key, fingerprint=fingerprint, request_fingerprint=REQUEST)[1].refresh_token == "refresh-2"
assert b.load(key, fingerprint="another-chain", request_fingerprint=REQUEST) == StoredTokenLookup(2, None)
@pytest.mark.asyncio
async def test_an_unloadable_credentials_key_keeps_tokens_in_process(session_factory, endpoint, monkeypatch, caplog):
def broken_key():
raise oauth_store.McpOAuthTokenKeyUnavailable("no key")
store = McpOAuthTokenStore(SqlMcpOAuthTokenRepository(session_factory))
monkeypatch.setattr(store, "_cipher", broken_key)
manager = OAuthTokenManager.from_extensions_config(_config(), token_store=store)
with caplog.at_level("WARNING", logger="deerflow.mcp.oauth"):
assert await manager.get_authorization_header(SERVER) == "Bearer access-1"
assert "in this process only" in caplog.text
assert _rows(session_factory) == []
@pytest.mark.parametrize("backend", ["memory", "sqlite"])
def test_gateway_shares_tokens_only_through_a_sql_database(backend, tmp_path):
from app.gateway.mcp_oauth_tokens import build_mcp_oauth_token_store
from deerflow.config.database_config import DatabaseConfig
database = DatabaseConfig(backend=backend, sqlite_dir=str(tmp_path))
store = build_mcp_oauth_token_store(SimpleNamespace(database=database))
if backend == "memory":
assert store is None
else:
assert isinstance(store, McpOAuthTokenStore)
assert build_mcp_oauth_token_store(SimpleNamespace()) is None
def test_forgetting_unconfigured_connections_deletes_only_that_owners_other_rows(session_factory, cipher):
store = McpOAuthTokenStore(SqlMcpOAuthTokenRepository(session_factory), cipher=cipher)
for owner, name in ((None, "kept"), (None, "removed"), ("alice", "kept"), ("alice", "removed"), ("bob", "removed")):
assert store.claim(oauth_store.McpOAuthTokenKey.for_server(name, owner), fingerprint="f" * 64, expected_version=None)
assert store.forget_unconfigured("alice", {"kept"}) == 1
assert store.forget_unconfigured(None, {"kept", "never-stored"}) == 1
assert [(row.scope, row.owner_user_id, row.server_key) for row in _rows(session_factory)] == [
("deployment", "", "kept"),
("personal", "alice", "kept"),
("personal", "bob", "removed"),
]
# An owner with no connections left loses every row.
assert store.forget_unconfigured("bob", set()) == 1
assert [row.owner_user_id for row in _rows(session_factory)] == ["", "alice"]
@pytest.mark.asyncio
async def test_a_revoked_stored_chain_asks_to_reauthorize_without_blaming_the_key(session_factory, cipher, endpoint):
assert await _replica(session_factory, cipher).get_authorization_header(SERVER) == "Bearer access-1"
endpoint.refresh_token = "revoked-by-the-provider"
_expire_access_tokens()
with pytest.raises(McpOAuthReauthorizationRequired) as rejected:
await _replica(session_factory, cipher).get_authorization_header(SERVER)
assert "configuring a new refresh token" in str(rejected.value)
assert "DEER_FLOW_CREDENTIALS_KEY" not in str(rejected.value)
(row,) = _rows(session_factory)
assert row.refresh_lease_owner is None, "a rejected refresh releases its lease"
assert endpoint.refresh_requests == ["refresh-0", "refresh-1"]
class _SlowStreamingTokenServer:
"""A real loopback token endpoint that streams its JSON body in slow chunks.
HTTPX timeouts bound each read, not the whole response, so a body that
keeps trickling in can outlive any fixed lease.
"""
def __init__(self, *, chunks: int, chunk_delay: float) -> None:
import http.server
import json as json_module
import socketserver
import time as time_module
self.requests: list[str] = []
self.started = threading.Event()
endpoint = self
current = {"refresh_token": "refresh-0", "generation": 0}
class Handler(http.server.BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, *args):
pass
def do_POST(self):
body = self.rfile.read(int(self.headers["Content-Length"])).decode()
submitted = parse_qs(body)["refresh_token"][0]
endpoint.requests.append(submitted)
endpoint.started.set()
if submitted != current["refresh_token"]:
payload = json_module.dumps({"error": "invalid_grant"}).encode()
self.send_response(400)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
return
current["generation"] += 1
current["refresh_token"] = f"refresh-{current['generation']}"
payload = json_module.dumps({"access_token": f"access-{current['generation']}", "refresh_token": current["refresh_token"], "expires_in": 120}).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Transfer-Encoding", "chunked")
self.end_headers()
size = -(-len(payload) // chunks)
for offset in range(0, len(payload), size):
piece = payload[offset : offset + size]
self.wfile.write(f"{len(piece):x}\r\n".encode() + piece + b"\r\n")
self.wfile.flush()
time_module.sleep(chunk_delay)
self.wfile.write(b"0\r\n\r\n")
self._server = socketserver.ThreadingTCPServer(("127.0.0.1", 0), Handler)
self._server.daemon_threads = True
self.url = f"http://127.0.0.1:{self._server.server_address[1]}/token"
self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
def __enter__(self):
self._thread.start()
return self
def __exit__(self, *exc):
self._server.shutdown()
self._server.server_close()
def test_a_slow_token_response_keeps_its_lease_until_the_token_lands(session_factory, cipher, monkeypatch):
"""The holder renews its lease while the body streams, so a peer waits instead of resending the consumed token."""
for variable in ("HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy"):
monkeypatch.delenv(variable, raising=False)
monkeypatch.setenv("NO_PROXY", "127.0.0.1,localhost")
monkeypatch.setattr(oauth_store, "REFRESH_LEASE_SECONDS", 0.6)
monkeypatch.setattr(oauth_store, "REFRESH_POLL_SECONDS", 0.05)
# About 1.8 s of streaming: three times the lease.
with _SlowStreamingTokenServer(chunks=6, chunk_delay=0.3) as server:
config = _config(token_url=server.url)
a = _replica(session_factory, cipher, config)
b = _replica(session_factory, cipher, config)
with ThreadPoolExecutor(max_workers=2) as executor:
first = executor.submit(lambda: asyncio.run(a.get_authorization_header(SERVER)))
assert server.started.wait(10), "replica A never reached the token endpoint"
second = executor.submit(lambda: asyncio.run(b.get_authorization_header(SERVER)))
assert first.result(timeout=20) == "Bearer access-1"
assert second.result(timeout=20) == "Bearer access-1"
assert server.requests == ["refresh-0"]
(row,) = _rows(session_factory)
assert (row.version, row.refresh_lease_owner) == (1, None)
assert cipher.decrypt_text(row.refresh_token_enc) == "refresh-1"
@pytest.mark.asyncio
async def test_a_token_request_that_never_finishes_gives_up_and_releases_the_lease(session_factory, cipher, endpoint, monkeypatch):
monkeypatch.setattr(oauth_store, "REFRESH_MAX_SECONDS", 0.3)
handle = endpoint.handle
async def stalled(request):
await asyncio.sleep(5)
return handle(request)
monkeypatch.setattr(endpoint, "handle", stalled)
with pytest.raises(RuntimeError, match="did not finish within"):
await asyncio.wait_for(_replica(session_factory, cipher).get_authorization_header(SERVER), 5)
(row,) = _rows(session_factory)
assert row.refresh_lease_owner is None
def _fail_next_writes(store: McpOAuthTokenStore, monkeypatch, count: int = 1) -> None:
"""Make the next ``count`` token writes fail like a database outage."""
original = store._repo.write
remaining = {"n": count}
def write(*args, **kwargs):
if remaining["n"] > 0:
remaining["n"] -= 1
raise sa.exc.OperationalError("UPDATE mcp_oauth_tokens", {}, Exception("database is unavailable"))
return original(*args, **kwargs)
monkeypatch.setattr(store._repo, "write", write)
@pytest.mark.asyncio
async def test_a_rotation_the_store_missed_is_stored_before_the_next_refresh(session_factory, cipher, endpoint, monkeypatch):
a = _replica(session_factory, cipher)
assert await a.get_authorization_header(SERVER) == "Bearer access-1"
_expire_access_tokens()
_fail_next_writes(a._token_store, monkeypatch)
# refresh-1 -> refresh-2, but the row keeps the consumed refresh-1.
assert await a.get_authorization_header(SERVER) == "Bearer access-2"
_expire_access_tokens()
# The live refresh-2 is stored first instead of being replaced by refresh-1.
assert await a.get_authorization_header(SERVER) == "Bearer access-3"
_expire_access_tokens()
assert await _replica(session_factory, cipher).get_authorization_header(SERVER) == "Bearer access-4"
assert endpoint.refresh_requests == ["refresh-0", "refresh-1", "refresh-2", "refresh-3"]
@pytest.mark.asyncio
async def test_a_missed_rotation_reaches_peers_while_the_access_token_is_still_valid(session_factory, cipher, endpoint, monkeypatch):
monkeypatch.setattr("deerflow.mcp.oauth._UNSAVED_ROTATION_RETRY_SECONDS", 0.0)
a = _replica(session_factory, cipher)
_fail_next_writes(a._token_store, monkeypatch)
assert await a.get_authorization_header(SERVER) == "Bearer access-1"
assert _rows(session_factory)[0].refresh_token_enc is None, "the first write failed"
# The next call, still within the token's lifetime, stores the rotation.
assert await a.get_authorization_header(SERVER) == "Bearer access-1"
_expire_access_tokens()
assert await _replica(session_factory, cipher).get_authorization_header(SERVER) == "Bearer access-2"
assert endpoint.refresh_requests == ["refresh-0", "refresh-1"]
@pytest.mark.asyncio
async def test_a_newer_peer_chain_supersedes_a_missed_rotation(session_factory, cipher, endpoint, monkeypatch):
a = _replica(session_factory, cipher)
assert await a.get_authorization_header(SERVER) == "Bearer access-1"
_expire_access_tokens()
_fail_next_writes(a._token_store, monkeypatch)
assert await a.get_authorization_header(SERVER) == "Bearer access-2"
# Meanwhile a peer stored a newer chain over version 1 (e.g. a provider with a reuse grace period).
peer = McpOAuthTokenStore(SqlMcpOAuthTokenRepository(session_factory), cipher=cipher)
key = oauth_store.McpOAuthTokenKey.for_server(SERVER, None)
oauth = _config().mcp_servers[SERVER].oauth
lease = peer.claim(key, fingerprint=oauth_config_fingerprint(oauth), expected_version=1)
assert lease is not None
assert peer.save(
key,
lease_owner=lease,
fingerprint=oauth_config_fingerprint(oauth),
request_fingerprint=oauth_token_request_fingerprint(oauth),
access_token="peer-access",
token_type="Bearer",
expires_at=_Clock.current + timedelta(seconds=900),
refresh_token="peer-refresh",
rotated=True,
)
_expire_access_tokens()
assert await a.get_authorization_header(SERVER) == "Bearer peer-access"
assert endpoint.refresh_requests == ["refresh-0", "refresh-1"]
@pytest.mark.asyncio
async def test_changed_token_request_parameters_refresh_from_the_stored_chain_instead_of_reusing_the_access_token(session_factory, cipher, endpoint):
assert await _replica(session_factory, cipher, _config(extra_token_params={"resource": "https://old-api"})).get_authorization_header(SERVER) == "Bearer access-1"
# A hot-reloaded server asks for another resource: the old access token is
# for the wrong audience, but its refresh-token chain is still the live one.
changed = _config(extra_token_params={"resource": "https://new-api"})
assert await _replica(session_factory, cipher, changed).get_authorization_header(SERVER) == "Bearer access-2"
assert endpoint.refresh_requests == ["refresh-0", "refresh-1"]
assert endpoint.forms[-1]["resource"] == ["https://new-api"]
# Processes with the new parameters now share the new access token.
assert await _replica(session_factory, cipher, changed).get_authorization_header(SERVER) == "Bearer access-2"
assert len(endpoint.refresh_requests) == 2
def test_request_fingerprint_follows_token_request_parameters_not_the_chain():
base = _config(extra_token_params={"resource": "https://api"}).mcp_servers[SERVER].oauth
fingerprint = oauth_token_request_fingerprint(base)
for update in ({"extra_token_params": {"resource": "https://other"}}, {"extra_token_params": {}}, {"token_field": "id_token"}):
changed = base.model_copy(update=update)
assert oauth_token_request_fingerprint(changed) != fingerprint, update
assert oauth_config_fingerprint(changed) == oauth_config_fingerprint(base), "the refresh-token chain is unaffected"
assert oauth_token_request_fingerprint(base.model_copy(update={"extra_token_params": {"resource": "https://api"}})) == fingerprint