【Bug已解决】Codex Desktop does not detect expired OAuth login state for plugin apps 解决方案原始报错线索Codex Desktop does not detect expired OAuth login state for plugin apps桌面应用没察觉插件所用的 OAuth 登录态已过期仍当作用户已登录。一、现象长什么样插件依赖某个 OAuth 登录态访问远端资源但桌面应用用户在网页端改了密码 / 点了「撤销授权」插件这边却还显示「已登录」令牌其实早就过了expires_in应用却没发现直到第一次真正发请求才收到401更糟的是应用拿到401后没走刷新流程直接把用户踢到登录页打断正在进行的任务有时令牌已过期但应用缓存里还是is_logged_inTrueUI 与真实状态长期不一致。 根因是**「登录态」被当成了一个静态布尔值而不是一个需要持续校验的生命周期对象**。二、背景OAuth 令牌的生命周期一个典型的 OAuth 2.0 登录态由几个字段组成{ access_token: eyJ..., refresh_token: v2.x..., expires_at: 1700000000, // 绝对过期时间戳 scope: remote.control }关键点access_token 短期有效几十分钟到几小时refresh_token 长期有效用来换新的 access_token令牌可被服务端主动吊销用户改密、撤销授权、安全风险都会让 refresh 也失效expires_in 是相对值必须换算成绝对expires_at才有判断意义。 「是否已登录」不能看「有没有 access_token」而要看「access_token 当前是否有效或能否用 refresh_token 换新」。三、为什么检测不到过期根因3.1 用静态布尔缓存登录态应用登录成功后写is_logged_in True到本地之后再也不更新。令牌早过期了布尔还是True。3.2 不做过期预判等 401 才反应从不主动检查expires_at每次都拿旧令牌去请求被服务端401了才慌。这叫「被动失败」体验差且易打断任务。3.3 收到 401 不静默刷新即便检测到失效应用直接报错登出而不是先用refresh_token换新的access_token重试。用户无谓重登。3.4 忽略「刷新也失败」 真登出refresh 返回invalid_grant说明令牌被吊销应清掉本地登录态并提示重新授权若忽略就会永远用死令牌重试。四、最小可运行复现静态布尔导致误判下面演示「只存布尔、不校验过期」如何把过期态误认为已登录import time class BadAuthStore: def __init__(self): self.is_logged_in False self.token None def login(self, token): self.token token self.is_logged_in True # 登录即置 True之后再不更新 def is_valid_now(self): # 只看布尔不看 token 是否过期 return self.is_logged_in if __name__ __main__: store BadAuthStore() store.login({access_token: x, expires_at: time.time() - 10}) # 已过期 print(误判为已登录?, store.is_valid_now()) # True —— 错误运行打印True但实际令牌已过期 10 秒。这就是「检测不到过期」的直接原因。五、解决方案一把登录态建成生命周期对象登录态不是一个布尔而是一个带expires_at的可校验对象并提供is_expired()/needs_refresh()import time class TokenSet: def __init__(self, access_token, refresh_token, expires_at, scope): self.access_token access_token self.refresh_token refresh_token self.expires_at expires_at self.scope scope def is_expired(self, skew: int 60) - bool: 提前 60 秒认为过期避免『刚好过期瞬间』的竞态。 return time.time() (self.expires_at - skew) def needs_refresh(self) - bool: 距离过期不足 5 分钟就主动刷新。 return time.time() (self.expires_at - 300) class AuthStore: def __init__(self): self.token: TokenSet | None None def login(self, ts: TokenSet): self.token ts def is_logged_in(self) - bool: # 真正的登录态 有 token 且未过期或被吊销需刷新由调用方处理 return self.token is not None and not self.token.is_expired() if __name__ __main__: store AuthStore() store.login(TokenSet(a, r, expires_attime.time() - 10, scopex)) print(正确判断已登录?, store.is_logged_in()) # False —— 过期即未登录把「是否登录」改成对expires_at的动态判断过期立刻反映出来。六、解决方案二静默刷新refresh_token 换 access_token检测到needs_refresh()时先用 refresh_token 换新失败再登出import requests # 真实库也可改用 urllib 标准库 def refresh_access(token: TokenSet, token_url: str) - TokenSet: 用 refresh_token 换取新的 access_token。 resp requests.post(token_url, data{ grant_type: refresh_token, refresh_token: token.refresh_token, }, timeout10) if resp.status_code ! 200: # refresh 也失败 - 令牌被吊销真登出 raise PermissionError(frefresh 失败: {resp.status_code} {resp.text}) data resp.json() return TokenSet( access_tokendata[access_token], refresh_tokendata.get(refresh_token, token.refresh_token), expires_atint(time.time()) data[expires_in], scopedata.get(scope, token.scope), ) def ensure_valid(store: AuthStore, token_url: str): 主动保证 token 有效需要刷新就静默刷新。 if store.token is None: return False if store.token.needs_refresh(): try: store.token refresh_access(store.token, token_url) except PermissionError: store.token None # 清掉登录态提示重新授权 return False return not store.token.is_expired()要点主动刷新优于被动 401refresh 失败必须把token置None并清登录态而不是假装还登录着。若不能装requests用标准库urllib.request同样可实现API 真实存在。七、解决方案三请求失败时的被动兜底 重试即便做了主动刷新仍可能因时钟偏差在请求瞬间过期。此时要在收到401时触发一次刷新并重试而不是立刻登出def authed_request(store: AuthStore, token_url: str, api_url: str, max_retry1): for attempt in range(max_retry 1): if not ensure_valid(store, token_url): raise PermissionError(未登录请重新授权) resp requests.get(api_url, headers{ Authorization: fBearer {store.token.access_token} }, timeout10) if resp.status_code ! 401: return resp # 收到 401令牌可能刚被吊销/过期强制刷新一次再试 store.token None # 触发下次 ensure_valid 重新登录路径 # 真实实现应调 refresh此处简化为抛出以走重新授权 if attempt max_retry: raise PermissionError(令牌失效需重新授权)被动兜底 主动刷新双保险既不打断任务又能在真吊销时干净登出。八、跨平台/跨插件注意点时钟偏差客户端时间不准会让expires_at判断错位建议用服务端返回的过期时间并留skew余量第五节已加 60 秒多个插件共享同一登录态一个插件被吊销不应影响其他插件除非是同一subject的主账号令牌scope 变化刷新后 scope 可能缩减需重新校验所需 scope持久化安全refresh_token 必须存系统钥匙串macOS Keychain / Windows Credential Manager / Linux Secret Service不能明文落盘吊销事件若服务端支持「撤销回调 / 令牌内省introspection」应订阅吊销事件做主动失效比轮询更及时。九、排查清单「检测不到 OAuth 过期」按下面排查登录态是布尔还是生命周期对象布尔必错改成带expires_at的校验有没有is_expired()/needs_refresh()是否留了 skew 余量防竞态是否主动刷新还是等 401 才反应被动失败刷新失败是否清登录态还是假装仍登录反复重试收到 401 是否重试一次再登出避免无谓打断多插件是否互相影响共享 vs 隔离要理清scope 是否重校验刷新后 scope 可能变refresh_token 存储安全吗明文落盘是重大隐患。十、小结「桌面应用检测不到插件 OAuth 登录态过期」的根因是把登录态当成静态布尔而非需要持续校验的生命周期对象。通用修复生命周期建模登录态 TokenSet提供is_expired()留 skew与needs_refresh()过期立刻反映第五节主动静默刷新needs_refresh()触发refresh_token换access_token失败即清登录态、提示重授权第六节被动兜底重试请求遇401先刷新重试一次再不行才登出不打断任务第七节安全与隔离refresh_token 存钥匙串、多插件 scope 隔离、订阅吊销事件。 一句话「是否已登录」不是登录那一刻决定的而是每一次使用前都要用expires_at和刷新能力重新证明的。把登录态从「开关」改成「会过期的凭证」过期检测就不再是问题——这与第 100 篇 scope 校验、第 110 篇计费幂等、第 88 篇权限一致性共同构成「认证状态必须被精确建模」的工程纪律。