# 为什么 OAuth 刷新令牌文件属于生产凭据？

复制的 `auth.json` 文件不是无关紧要的配置残留。如果它包含刷新令牌，那它就是生产凭据。即使复制它的人已经下班，这个凭据仍可能继续生成访问令牌。把机器称为无头设备不会改变这一点。它只是去掉了浏览器提示，也去掉了原本会提醒人们思考凭据归属的时刻。

我见过一些团队小心保护 API 密钥，却因为访问令牌很快过期，就在聊天附件中把刷新令牌文件发给构建主机。这完全本末倒置。短期访问令牌通常是文件里最不值得关注的部分。真正危险的是续期路径，因为攻击者、权限过大的代理，或无人负责的夜间任务，都可能持续使用它。

## 刷新令牌文件是一组凭据

OAuth 刷新令牌文件之所以是一组凭据，是因为它通常包含足够的状态，可以在没有人工参与的情况下获取新的 bearer 令牌。具体字段取决于客户端库，但危险的组合很常见：刷新令牌、客户端标识符、令牌端点、已授予的权限范围，有时还包括客户端密钥或设备专用断言。

不要因为文件扩展名而低估风险。JSON 只是一个外壳。如果 `.cache/session.json`、`token-store.json` 或 `auth.json` 能够续期生产服务的访问权限，就应该按照私有 SSH 密钥的方式管理它们。

OAuth 2.0 授权框架 RFC 6749 将刷新令牌描述为用于获取访问令牌的凭据。它还说明，授权服务器可以签发新的刷新令牌，客户端必须丢弃旧令牌。人们很容易把这句话当成协议细节略过。但在无人值守系统中，它是一项运维要求：两个工作进程如果都认为自己持有当前文件，就可能同时争夺同一个凭据的身份。

在清单中分别记录以下三项：

- 访问令牌在有限时间内授权一次请求。
- 刷新令牌授权续期，通常可以跨越多个访问令牌的生命周期。
- OAuth 客户端标识发起续期请求的软件。

团队经常把前两者混为一谈，然后得出错误的过期结论。五分钟有效的访问令牌，并不能让复制出来的刷新令牌变得安全。它可能只意味着，复制的文件可以每隔五分钟为入侵者生成一批新令牌。

生产清单中的一条记录，应该回答的不只是“哪个服务在使用它？”还要记录授权服务器、OAuth 客户端标识符、资源服务器、主体或服务账户、确切的已授予权限范围、环境、签发时间（如果可用）、续期负责人、获准的执行路径和撤销方式。如果这些字段无法填写，你手里就没有一份可以投入自主运行的凭据，只有一个恰好在测试中可用的文件。

## 无头运行容易让所有权失去踪迹

无头代理需要一种明确负责人的续期安排。常见的失败路径是：开发者使用自己的账户为本地工具授权，然后因为服务器无法完成浏览器流程，就把缓存复制到服务器。任务能够运行，于是这个副本变成了永久配置。

现在问问那些常被跳过、却有些尴尬的问题。这个令牌代表开发者、团队，还是任务？谁可以在不影响其他人工作的情况下撤销它？哪个代码仓库或代理指令告诉任务去哪里寻找它？员工离职后，能否让背后的身份失效？服务商的授权页面显示的是个人账户吗？可任务的行为明明更像一个服务。

“平台账户拥有它”不是答案，除非这个账户有明确记录的管理员、恢复流程和有限权限。个人登录复制出来的令牌，在某个方面比明文 API 密钥更糟：它通常以不透明的缓存文件形式出现，审查者看不出它附带了哪些权限。

如果服务商支持，请使用专用服务身份。只授予该身份完成任务所需的最小资源权限。为每个有实际信任边界的环境分别注册 OAuth 客户端，例如开发、预发布和生产环境。不要因为续期方便，就使用一个权限宽泛的客户端和一份权限宽泛的授权。

有一个区别必须保持清晰：OAuth 客户端身份和资源身份是两种不同的控制。客户端标识符说明哪个软件请求了令牌。主体和权限范围说明令牌可以访问谁的资源，以及可以执行什么操作。团队即使创建了专用客户端，如果仍然让它以人类管理员身份获得授权，也只是稍微改善了归因，并没有解决权限问题。

对于代理，请在部署文档旁边写一份简单的所有权记录，不要把它放进令牌文件：

```text
Credential name: billing-export-prod
OAuth client: agent-billing-prod
Resource identity: svc-billing-export
Scopes: reports.read, exports.write
Renewal owner: platform-oncall
Execution path: production job runner through credential broker
Revocation: authorization server admin console and RFC 7009 endpoint
```

这份记录刻意写得平淡无奇，所以它能在凌晨两点派上用场。操作员可以据此撤销正确的授权，而不必猜测文件属于开发者旧笔记本、预发布测试，还是正在触发告警的任务。

## 把续期路径放在代理工作区之外

代理进程根本不应该读取刷新令牌文件。如果代理能读到令牌字符串，它就能把令牌打印出来、写入日志、嵌入补丁、发送给远程工具，或者留在崩溃报告中。针对提示词制定的保密指令，并不能改变进程外传它能够读取的数据这一事实。

文件权限仍然重要，但它们只是架构选择之后的一层限制。在常规 Unix 主机上，类似 `0600` 的文件模式可以阻止其他本地账户访问。它无法阻止获得授权的代理进程、代理插件、子进程、调试器或备份任务读取文件，也无法解释为什么这台主机一开始就拥有生产环境的续期凭据。

将刷新令牌放进密钥管理器、操作系统凭据存储，或让代理无法查询原始值的本地代理中。代理应接受范围明确的操作请求，在内部获取或刷新凭据，调用获准的资源端点，然后只返回代理需要的结果。请求可以是这样：

```json
{
  "action": "create_export",
  "target": "billing-api",
  "parameters": {
    "report_date": "2026-07-23"
  }
}
```

代理收到的应该是这样的响应，而不是令牌：

```json
{
  "status": "accepted",
  "export_id": "exp_4821",
  "report_date": "2026-07-23"
}
```

这个边界可以避免一种常见错误：把密钥目录挂载到每个任务容器中，然后称之为受控访问。挂载会让容器中的每个库、shell 命令、扩展和意外的诊断转储都能接触到密钥。代理可以拒绝未知目标，由自己附加正确凭据，并让续期状态留在代理内存之外。

在 macOS 上，Sallyport 对支持的 HTTP 和 SSH 操作采用了这种模式：凭据保留在加密保险库中，代理收到的是操作结果，而不是明文密钥。这种设计很有价值，因为它授权的是请求本身，而不是把令牌文件当作方便转交的东西。

不要把刷新令牌文件放进代码仓库目录、CI 工作区、共享网络文件夹、主目录下的点文件、容器镜像或通用备份路径。每个位置都会产生一种独立的复制机制，每个副本都会带来新的未来撤销问题。如果旧工具坚持要求某个路径，就给它一个由凭据助手管理的短期隔离运行目录，再由助手负责创建和删除文件。把这当作有截止日期的兼容性例外，不要把它当作标准模式。

## 权限范围应该描述一项任务，而不是整个部门

无头代理持有的权限范围，应该描述它的一项具体任务。因为代理将来可能需要访问另一个项目，就给它授予读取所有项目的权限，最终它总会在没人预料的场景中使用这些权限。权限范围之所以容易变宽，是因为授权页面和服务商文档可能让人感到麻烦。代价会在事故发生时出现：撤销一个自动化令牌，也会中断无关工作。

从最终要调用的 API 开始，而不是从服务商提供的权限列表开始。写下任务所需的操作和资源。一个需要获取发票并上传完成的导出文件的任务，可能只需要读取发票的权限，以及向某个导出位置写入的权限。它不需要用户管理、删除代码仓库、修改账单或管理权限的范围，只因为某个人在初始设置时使用过这些权限。

OAuth 权限范围本身还不够。资源侧权限可能会扩大一个看似普通的范围的影响。即使令牌只有 `files.write`，只要主体可以访问每个团队文件夹，它仍然可能破坏大量资源。如果服务商允许，请把服务身份绑定到有限的项目、文件夹、组织单元或代码仓库。然后测试反向场景：对相邻生产资源的操作应该因为权限不足而失败，而不只是因为代理还没有尝试过。

即使服务商接受合并后的权限列表，也应为不同职责建立不同授权。例如，把只读数据收集任务和发布结果的任务分开。泄露的发布者凭据与读取者凭据有不同的影响、轮换节奏和批准负责人。把它们合并只省下一次令牌续期流程，却会让每次调查都更困难。

避免根据人类管理员的会话来设计权限范围。管理员在设置时往往需要宽泛权限，因此会同意这些权限。运行中的代理并不会继承管理员的判断力，只会继承管理员的授权。

一个实用的审查方法是：阅读授权记录，并尝试用一句话描述这项任务。如果描述变成“它可以管理一些我们以后可能需要的东西”，这份授权就还没准备好。缩小任务范围，或把任务拆开。维护两份凭据的麻烦，比发现导出代理竟然可以修改身份设置的代价低得多。

## 自动刷新令牌需要唯一的状态负责人

刷新令牌轮换会带来状态管理问题，无人值守的工作进程必须有意识地解决它。许多授权服务器都会轮换刷新令牌：刷新成功后，服务器返回替代令牌，并可能让之前的令牌失效。RFC 9700《OAuth 2.0 安全最佳实践》建议公共客户端使用刷新令牌轮换或发送方约束的刷新令牌来检测重放。这是合理的安全建议，但它并不会让共享文件变得安全。

考虑一种常见故障。工作进程 A 和工作进程 B 从同一个挂载的 `auth.json` 开始。A 先刷新并获得 `R2`，服务商让 `R1` 失效。A 还没来得及写入 `R2`，进程就崩溃了，或者文件写入落在 B 看不到的本地层中。B 发送 `R1`，收到 `invalid_grant`，然后重试。操作员看到任务失败，从备份中复制旧缓存，结果事故响应又制造了更多凭据副本。

可以使用以下安排之一：

1. 由单一凭据代理负责刷新，并以事务方式保存替代令牌。
2. 由一个定时工作进程负责某项授权，设置明确的锁，不允许共享令牌状态的副本并行运行。
3. 为不同工作进程创建不同授权，让每个刷新令牌只有一个写入者。

第一种安排通常最干净。第二种适合规模较小、受严格控制的任务，但锁的过期和崩溃恢复需要认真设计。第三种需要更多授权和生命周期管理工作，却能很好地控制故障范围。

如果服务商允许，不要通过关闭轮换来解决竞争问题。这个建议很有吸引力，因为它掩盖了并发错误，也会让被盗刷新令牌有更多时间在不被察觉的情况下运行。应当修复所有权模型。

持久化路径必须以原子方式更新新令牌，并保留足够的元数据来识别过时的写入者。至少应保存版本、上次成功刷新时间，以及可以安全记录的稳定凭据标识符。如果版本 15 已经存在，密钥存储应拒绝声称要替换版本 14 的更新。简单覆盖会让较慢的工作进程重新写入过时状态。

当授权服务器提供发送方约束令牌时，要弄清楚约束绑定的对象。证明机制可以让复制的令牌在没有对应客户端持有的密钥时变得不那么有用。但它不能替代对该密钥的访问控制，也不能把人类授权变成适当的服务身份。把它当作另一道防线，而不是分发缓存文件的理由。

## 轮换是一份运行手册，不是日历提醒

只有当有人可以无需临时发挥就执行时，轮换政策才可信。按日历定期轮换有其作用，但负责人变更、可疑活动、主机遭入侵、代码仓库暴露以及意外的 `invalid_grant` 错误，都应该触发同一套准备好的流程。怀疑复制的令牌已经泄露时，不要等到计划日期。

一份可执行的运行手册应包含五个动作：

1. 冻结受影响的代理或凭据代理路径，使其在变更期间无法继续刷新。
2. 根据所有权记录和日志，确认 OAuth 客户端、资源身份、权限范围和凭据版本。
3. 在授权服务器处撤销刷新令牌或授权。如果服务商提供控制台或兼容 RFC 7009 的撤销端点，请使用它。
4. 删除所有已知的运行时副本，并让任何可能恢复它的备份或缓存进程失效。
5. 重新注册专用身份，测试允许的最小操作，并记录替代版本。

RFC 7009 定义了令牌撤销，并允许服务器在处理请求时一并撤销相关令牌和授权。因此，操作员在点击撤销前必须了解影响范围。服务商可能会让当前访问令牌、整个刷新令牌家族，或完整授权失效。正确的做法不是避免撤销，而是记录哪些任务共享授权，避免它们意外共用一份授权。

使用一次性的非生产身份测试轮换。确认新签发的替代令牌有效，旧令牌失败，过时的工作进程无法覆盖新状态。然后演练令牌被撤销后的响应。你希望任务以可识别的错误和可创建工单的身份安全失败，而不是悄悄退回开发者缓存的登录状态。

备份需要特殊处理。加密备份在保留期结束前仍会保存刷新令牌。你可能无法立即删除历史备份块，但可以立即撤销暴露的授权。记录保留位置，让调查人员知道恢复介质中包含一个已经失效的秘密，即使它已经无法使用。

## 分开审计操作和续期事件

审计日志必须同时记录续期事件，以及使用由此产生的访问令牌执行的操作。刷新事件说明凭据仍处于活跃状态，却无法说明代理读取了一份报告，还是修改了一千条记录。反过来，如果 API 操作日志没有凭据版本，你就无法确认哪个续期路径授权了该操作。

为每次刷新记录安全元数据：时间戳、凭据标识符、刷新前后的令牌版本、OAuth 客户端标识符、主体标识符、请求或返回的权限范围（如果服务商提供）、执行主机或代理身份，以及结果。为每次受保护操作记录安全元数据：代理会话或任务标识符、请求目标、操作、资源标识符、授权决定、结果代码和关联标识符。

绝不要记录刷新令牌、访问令牌、授权码、客户端密钥、完整回调 URL 或原始 Authorization 请求头。如果日志收集器、终端记录器或错误监控工具已经收到事件，再做脱敏就太晚了。构建从来不接受这些字段的日志调用，不要依赖每个调用方都记得使用清理器。

一个实用的事故查询应该从资源操作开始向回追踪。假设某个导出出现在错误的目标位置。你应该能够回答：哪个任务请求了它，哪个进程运行了任务，使用的是哪个 OAuth 客户端，哪个凭据版本提供了访问权限，该版本何时签发，以及是否还有其他主机使用了相同版本。如果其中任何一环依赖读取令牌值，审计设计就是有问题的。

当代理在没有持续监督的情况下执行操作时，防篡改能力很重要。保存在同一台主机上的追加式日志总比毫无记录好，但控制主机的攻击者可能同时修改缓存和记录。应将审计数据保存在受保护的系统中，并独立验证其完整性。对于能够保留加密哈希链记录的系统，离线验证可以让调查人员在不暴露底层秘密的情况下检查日志条目是否发生变化。

## 应该批准操作，而不是批准暴露令牌

当代理试图影响外部系统时，人工批准最有价值。一次性批准刷新令牌文件，然后让任何进程在数周内使用它，看起来像有控制，实际上却让敏感字符串不断流转。

根据后果选择批准方式。只读获取可以在事先批准的狭窄会话中运行。向新目标发送数据、修改权限、删除资源，或在通常任务之外使用凭据，都应该停下来等待人工决定。批准者需要看到请求进程、目标、操作以及足够理解影响的参数。笼统的“允许 OAuth”提示几乎没有用。

不要把频繁提示和安全混为一谈。如果每次无害调用都要求同意，人们会形成肌肉记忆，直接点击批准。为常规工作设置合理的会话边界，再为高影响操作单独要求批准。无论代理运行在厨房餐桌旁、旅行网络中，还是夜间任务里，这个决定都应该保持可见。

判断方法很简单：如果代理指令变得恶意，或某个插件行为异常，它能否在不经过让人看到后果的控制点时，就把访问权限转化为外部操作？如果答案是“可以”，因为它已经持有 `auth.json`，那就把凭据放到代理后面，并重新设计请求接口。

## 把旧的 auth.json 文件当作迁移项目

现有的令牌缓存很少能在一个下午内全部消失，但因为迁移麻烦就让它们继续处于未记录状态，必然会使它们变成永久配置。先找出每个使用者，然后按服务、主体、权限范围、环境、写入者数量和存储路径对文件分类。撤销那些无法归属的副本。没人负责的令牌没有理由继续自动续期。

一次迁移一个工作流。先创建专用资源身份和客户端。接着把它的刷新令牌放到选定的密钥存储或代理后面。然后修改任务，让它提交获准的操作请求，并将新的审计记录与旧任务的输出进行比较。确认替代方案运行正常后，再撤销旧的个人授权。

预计会有一些工具与你作对。有些 SDK 默认拥有本地 JSON 缓存，看到缓存后就会静默刷新。把这些工具放进受限制的兼容性包装器中，只允许一个进程读取文件，代理不得直接访问。将移除包装器的任务加入服务负责人的工作队列。“库就是这么要求的”可以解释临时例外，却不能为永久的秘密泄露路径提供正当理由。

第一个迁移目标应该是权限范围最宽，或负责人最不明确的文件，而不是最容易移动的文件。这些凭据最容易把一次原本可控的代理错误变成生产事故。成功迁移一个工作流后，应通过部署审查、模板修改，以及拒绝将原始凭据缓存挂载到代理运行环境中，让旧模式难以重现。

刷新令牌应该只有一个明确负责人、一条受控续期路径，以及一份记录所有外部操作的审计轨迹。如果当前的 `auth.json` 无法满足这些条件，那么就应在替代路径验证可行后撤销它。
