fix(dingtalk): validate access token expiry (#7450)

* fix(dingtalk): validate access token expiry

* fix(dingtalk): reject token lifetimes within refresh margin

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
ShiYuan Xiong 2026-10-25 08:57:28 +08:00 committed by GitHub
parent 63211d5da6
commit b3b1424799
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 40 additions and 13 deletions

View File

@ -1346,6 +1346,8 @@ Scheduled-task result and lifecycle notices can also be pushed to a bound Feishu
DingTalk's Markdown fallback displays tables as labeled blockquotes, preserving escaped pipes (`\|`) within cells and empty leading or trailing cells so values stay under the correct column labels.
DingTalk token caching uses a two-hour fallback when the provider's token lifetime is invalid or no longer than the five-minute refresh margin, avoiding a refresh on every request.
When DeerFlow runs in Docker Compose, IM channels execute inside the `gateway` container. In that case, do not point `channels.langgraph_url` or `channels.gateway_url` at `localhost`; use container service names such as `http://gateway:8001/api` and `http://gateway:8001`, or set `DEER_FLOW_CHANNELS_LANGGRAPH_URL` and `DEER_FLOW_CHANNELS_GATEWAY_URL`.
**Commands**

File diff suppressed because one or more lines are too long

View File

@ -1101,14 +1101,20 @@ class DingTalkChannel(Channel):
raise ValueError("DingTalk access token response did not contain a usable accessToken")
raw_expires_in = data.get("expireIn", 7200)
now = time.monotonic()
try:
expires_in = int(raw_expires_in)
except (TypeError, ValueError):
# A lifetime within the refresh margin is already stale.
if expires_in <= _TOKEN_REFRESH_MARGIN_SECONDS:
raise ValueError
expires_at = now + expires_in - _TOKEN_REFRESH_MARGIN_SECONDS
except (TypeError, ValueError, OverflowError):
logger.warning("[DingTalk] invalid expireIn value %r, using default 7200s", raw_expires_in)
expires_in = 7200
expires_at = now + expires_in - _TOKEN_REFRESH_MARGIN_SECONDS
self._cached_token = access_token.strip()
self._token_expires_at = time.monotonic() + expires_in - _TOKEN_REFRESH_MARGIN_SECONDS
self._token_expires_at = expires_at
return self._cached_token
@staticmethod

View File

@ -1023,22 +1023,23 @@ class TestAccessTokenValidation:
_run(go())
def test_invalid_expire_in_uses_default(self):
@pytest.mark.parametrize("expire_in", ["invalid", float("inf"), 10**400, 0, -1, 1, 100, 299, 300, "300"])
def test_invalid_expire_in_uses_default(self, expire_in):
async def go():
import time
from unittest.mock import patch
bus = MessageBus()
channel = DingTalkChannel(bus, config={})
channel._client_id = "k"
channel._client_secret = "s"
call_count = 0
class FakeResponse:
def raise_for_status(self):
pass
def json(self):
return {"accessToken": "tok_ok", "expireIn": "invalid"}
return {"accessToken": "tok_ok", "expireIn": expire_in}
class FakeClient:
async def __aenter__(self):
@ -1048,20 +1049,29 @@ class TestAccessTokenValidation:
pass
async def post(self, url, **kwargs):
nonlocal call_count
call_count += 1
return FakeResponse()
before = time.monotonic()
with patch("app.channels.dingtalk.httpx.AsyncClient", return_value=FakeClient()):
with (
patch("app.channels.dingtalk.httpx.AsyncClient", return_value=FakeClient()),
patch("app.channels.dingtalk.time.monotonic", return_value=1000.0) as clock,
):
token = await channel._get_access_token()
clock.return_value = 1005.0
cached_token = await channel._get_access_token()
assert token == "tok_ok"
assert channel._token_expires_at > before
assert cached_token == "tok_ok"
assert channel._token_expires_at == 7900.0
assert call_count == 1
_run(go())
class TestTokenCaching:
def test_token_is_cached_across_calls(self):
@pytest.mark.parametrize(("expire_in", "expected_expiry"), [(301, 1001.0), (3600, 4300.0), (7200, 7900.0)])
def test_token_is_cached_across_calls(self, expire_in, expected_expiry):
async def go():
from unittest.mock import patch
@ -1077,7 +1087,7 @@ class TestTokenCaching:
pass
def json(self):
return {"accessToken": "tok_abc", "expireIn": 7200}
return {"accessToken": "tok_abc", "expireIn": expire_in}
class FakeClient:
async def __aenter__(self):
@ -1091,13 +1101,22 @@ class TestTokenCaching:
call_count += 1
return FakeResponse()
with patch("app.channels.dingtalk.httpx.AsyncClient", return_value=FakeClient()):
with (
patch("app.channels.dingtalk.httpx.AsyncClient", return_value=FakeClient()),
patch("app.channels.dingtalk.time.monotonic", return_value=1000.0) as clock,
):
t1 = await channel._get_access_token()
clock.return_value = 1000.5
t2 = await channel._get_access_token()
assert channel._token_expires_at == expected_expiry
assert call_count == 1
clock.return_value = expected_expiry
t3 = await channel._get_access_token()
assert t1 == "tok_abc"
assert t2 == "tok_abc"
assert call_count == 1
assert t3 == "tok_abc"
assert call_count == 2
_run(go())