fix(mcp-server): device-code 一次性作废失效修复(consume 集群 Redis 删除未生效)

初版 consume_device_code 用裸 redis_client.delete() 且吞异常,在集群 Azure
Redis 上删除未生效,导致一个 device_code 换发 token 后仍能在每次 >interval
的轮询继续换发新 token —— 违反 RFC 8628 一次性语义与验收「换一次后再用→拒绝」。

初测二次轮询都在 slow_down 窗口内(<5s)被限流响应遮住,未暴露;>5s 公网
真实轮询复测才暴露。

修复:consume 改用已验证可靠的 _set_keepttl 置 status=consumed(token 端点
签发前硬检查 consumed → expired_token),并 best-effort 删除 device_code +
device_user_code 两个 key。即使集群删除失败,状态位硬拦截。

复测(公网 APIM 真实路径,间隔 >5s):首 poll 签发 → 二/三次 poll 均
expired_token,不再重复签发。

镜像 device-code-fix2-20260722-arm64 @sha256:716c2e2d 已部署生产 3/3 Running。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 15:24:27 +08:00
co-authored by Claude Opus 4.8
parent f5f0218233
commit b754af8ba1
4 changed files with 296 additions and 8 deletions
+22 -6
View File
@@ -159,12 +159,28 @@ async def mark_poll(device_code: str, state: dict) -> bool:
return await _set_keepttl(_DC_PREFIX + device_code, json.dumps(state))
async def consume_device_code(device_code: str) -> None:
"""token 换发后作废该 device_code(一次性)。"""
state = get_state()
if not state.redis_client:
async def consume_device_code(device_code: str, state: Optional[dict] = None) -> None:
"""token 换发后作废该 device_code(一次性)。
先用**已验证可靠**的 _set_keepttl 把状态置 consumed(token 端点在签发前会
检查 status==consumed → expired_token,这是硬拦截),再 best-effort 删除 key。
裸 delete 在集群 Redis 上可能抛 MOVED 被吞掉,故不能只依赖删除。
"""
# 1) 置 consumed(权威拦截;保留原 TTL,不续命)
if state is not None:
st = dict(state)
st["status"] = "consumed"
ok = await _set_keepttl(_DC_PREFIX + device_code, json.dumps(st))
if not ok:
logger.error("device_auth_consume_mark_failed", key_prefix=(_DC_PREFIX + device_code)[:24])
# 2) best-effort 物理删除(清 Redis,同时清 user_code 反查)
rs = get_state()
if not rs.redis_client:
return
try:
await state.redis_client.delete(_DC_PREFIX + device_code)
uc = (state or {}).get("user_code") if state else None
await rs.redis_client.delete(_DC_PREFIX + device_code)
if uc:
await rs.redis_client.delete(_UC_PREFIX + uc)
except Exception as e: # noqa: BLE001
logger.warning("device_auth_consume_failed", error=str(e))
logger.warning("device_auth_consume_delete_failed", error=str(e))
+1 -1
View File
@@ -1613,7 +1613,7 @@ async def device_token(payload: _DeviceTokenRequest, db: AsyncSession = Depends(
}
access_token = create_access_token(data=token_data)
refresh_token = create_refresh_token(data=token_data)
await consume_device_code(dc) # 一次性作废
await consume_device_code(dc, state) # 一次性作废(置 consumed + 删 key)
logger.info("device_token_issued", user_id=str(user.id), client=state.get("client"))
return SuccessResponse(data={
"token": access_token,