# API 令牌过期：长期代理任务的恢复规则

当令牌过期没有明确负责人时，长期运行的代理任务会以一种特别愚蠢的方式失败。代理看到 401，就重复同一个调用，消耗速率限制额度，有时还会把一次结果不确定的写入变成多次写入。这不是认证故障，而是恢复设计失败。

应将过期视为预期中的状态变化。让一个组件负责刷新，在采取行动前先对错误分类，根据操作语义限制重试，并留下记录，让操作员能够判断远程服务是否已经接受了工作。代理不应该猜测自己能否生成凭据，也不应该猜测再次发送写入是否安全。

## 过期是状态变化，不是异常中断

API 令牌过期说明短期凭据的授权期限已经结束，但这并不说明任务失败。长期运行的任务可能已经完成十次远程操作，然后在下一次请求中遇到过期。恢复代码必须保留这一区别。

团队常常把四种不同事件合并到一个名为 `auth_failed` 的分支中。这种捷径会造成错误行为，因为每种事件都需要不同的响应：

- 过期表示令牌生命周期结束，有权负责刷新的组件可以请求新的访问令牌。
- 撤销表示用户、管理员或服务提供商收回了授权，刷新可能会按设计失败。
- 请求认证无效可能表示标头格式错误、凭据类型错误、签发者不匹配或受众不匹配。
- 权限不足表示身份仍然有效，但无法执行这项操作。

OAuth 会明确区分这些情况是有原因的。RFC 6750 规定了 `invalid_token` bearer 令牌错误，并说明当令牌过期、被撤销、格式错误或以其他方式无效时，资源服务器应返回带有 `WWW-Authenticate` 挑战的 401 响应。这是有用的协议指导，但并不意味着客户端可以盲目刷新。资源服务器只是在报告它拒绝了这次请求。

任务还有两条时间线。**工作时间线**记录它发现、计算、创建和确认的内容。**授权时间线**记录允许它采取行动的凭据代次。令牌过期时，应保留工作时间线，并将授权时间线转为 `renewing` 或 `blocked`。不要重置整个任务，然后把这种做法称为恢复。

这个区别对代理尤其重要，因为代理会执行一连串相互依赖的调用。比如，代理创建了一个变更请求，上传了一个构件，然后在附加构件之前失去访问权限。如果从第一条指令重新开始，就可能创建第二个变更请求。在每次确认远程效果后保存持久化检查点，代理就能从缺失的附加操作继续，而不是重放整个计划。

应使用明确状态，而不是 `authenticated` 这样的布尔值：

```text
ready -> executing -> authorization_expired -> renewal_in_progress
renewal_in_progress -> executing
renewal_in_progress -> authorization_blocked
executing -> outcome_unknown
outcome_unknown -> reconciled -> executing
```

`outcome_unknown` 值得拥有独立状态。服务可能已经提交了写入，但连接在调用方收到响应之前断开。令牌刷新无法解决这种不确定性。任务必须先通过操作标识符、幂等令牌或服务提供商专用查询进行查询，再尝试重新写入。

## 必须由一个组件负责刷新

持有刷新凭据的客户端应负责刷新访问令牌，而代理应请求执行操作，不应接收可刷新的凭据。这条规则听起来很严格，直到两个代理同时遇到过期问题。

如果每个工作进程都保存一份刷新令牌副本，每个工作进程就能独立刷新。它们会相互竞争，生成充满无关凭据事件的记录；如果刷新令牌发生轮换，还可能让一个工作进程持有的令牌失效。更重要的是，你把每个能读取任务的进程都变成了长期身份持有者。

应在代理与服务提供商之间放置凭据代理或操作网关。代理保存刷新凭据或服务凭据，获取短期访问令牌，仅在执行请求时附加其中一个，然后返回响应。代理既不会收到访问令牌，也不会收到刷新令牌。

这种分工让每个参与者都有清晰职责：

- 代理决定要执行哪项获准操作，并提供操作输入。
- 网关检查会话是否可以发起请求，选择凭据引用，并执行调用。
- 当服务提供商报告已分类的过期失败时，刷新负责人执行一次刷新。
- 操作员处理授权被撤销、新的同意要求，或需要人工介入的凭据问题。

不要因为代理可以调用 OAuth 端点，就让代理负责刷新。能力和权限是两回事。能请求日历事件或部署构件的代理，并不自动需要延长某个人或某项服务身份的有效期。

RFC 6749 将刷新令牌描述为发给客户端、用于获取新访问令牌的凭据。在架构中应按字面理解“客户端”。如果代理不是注册客户端，就不应因为它发出了 API 请求而继承客户端的刷新令牌。

确实存在任务本身负责刷新的合理场景。一个范围严格限定的机器工作负载，如果拥有自己的注册客户端、独立的存储边界，并且不代表任何人执行操作，就可以这样做。即便如此，同一身份的所有并发操作也应由一个刷新协调器服务。使用互斥锁或以凭据引用为键的 single-flight 机制。第一个失败的调用负责刷新，其他调用等待结果，而不是同时冲击令牌端点。

一份简单的所有权记录可以避免模糊设计：

```json
{
  "credential_ref": "billing-write-prod",
  "renewal_owner": "action-gateway",
  "access_token_lifetime": "provider-defined",
  "refresh_allowed": true,
  "reauthorization_owner": "on-call-operator",
  "concurrent_refresh": "single-flight"
}
```

记录中只包含引用，绝不包含凭据本身。同时，它还写明了刷新失效后谁必须采取行动。如果在部署前没人能回答这个问题，任务就会在夜间以糟糕的方式回答它。

## 401 触发刷新前需要证据

只有响应和凭据记录共同支持“过期”诊断时，才应刷新。把每个 401 都当成过期，会掩盖配置缺陷，还可能产生一长串毫无意义的刷新尝试。

先查看服务提供商记录的错误正文、标头和令牌格式要求。一些 API 返回兼容 OAuth 的 `WWW-Authenticate` 值，另一些返回 JSON 错误代码。有些 API 将认证失败放在使用不同状态码的网关后面。分类器应使用该服务提供商记录的信号，然后在无法确定时安全地返回终止性错误。

下面是一份可行的分类契约：

```json
{
  "http_status": 401,
  "provider_code": "invalid_token",
  "www_authenticate": "Bearer error=\"invalid_token\"",
  "credential_ref": "reports-read",
  "token_generation": 17,
  "decision": "renew_once"
}
```

只有在以下条件全部满足时，响应才可以判定为 `renew_once`：请求使用了网关签发或选择的凭据；该凭据存在可刷新的路径；服务提供商的信号符合文档所述的过期或无效令牌条件；并且该任务尚未刷新过第 17 代凭据。

不同失败类别应返回不同结果。标头格式错误应归入 `configuration_error`，由开发人员检查请求构建器。受众不匹配应归入 `credential_binding_error`，由相关人员修复令牌请求或资源配置。授权被撤销应归入 `reauthorization_required`，系统停止外部操作，并准确告知正确的操作员需要为哪个身份重新同意。403 应归入 `permission_denied`，刷新它只是机械式的错误操作。

时钟问题会造成数量惊人的误判。根据本地时间计算过期时间的客户端可能过早拒绝仍然可用的令牌，而时钟漂移的客户端可能发送已过期的令牌。收到令牌时记录签发方提供的过期时间，保留少量安全余量，并使用可信的系统时钟。不要让每个代理都根据解码后的令牌声明自行计算过期时间。这会重复协议逻辑，并造成彼此不一致。

不要仅仅为了判断能否信任令牌而检查令牌载荷。JSON Web Token 可能包含 `exp` 声明，但解码其 base64url 载荷并不能验证签名、签发者、受众或撤销状态。只有在接收它的组件完成服务提供商规定的验证后，才能将它作为提示使用。不透明访问令牌没有可检查的载荷，这也是让调用方依赖响应处理而不是研究令牌内容的另一个好理由。

## 重试限制可以保护远程系统和证据

在确认属于过期后，应允许一次协调刷新和一次受控重放。更多重试不会改善授权，通常只会掩盖损坏的刷新路径，让审计记录更难阅读。

重放规则取决于操作能做什么。读取请求通常可以在刷新后重放。写入请求需要更强证据，因为远程服务可能已经处理了请求，只是过期响应、超时或连接中断先到达了调用方。

设计操作接口时就应对操作分类：

| 操作类别 | 示例 | 刷新后的恢复方式 |
| --- | --- | --- |
| 读取 | 获取记录 | 如果请求没有外部副作用，可重放一次 |
| 幂等写入 | 按已知版本替换文档 | 只有在服务提供商保证该方法和条件具备幂等性时，才重放一次 |
| 带幂等令牌的写入 | 创建发票草稿 | 使用完全相同的令牌和载荷重放一次 |
| 非幂等写入 | 发送消息或触发付款 | 先协调确认，只有远程系统确认此前没有产生效果时才执行 |

HTTP 方法名称本身不能决定这些问题。`PUT` 通常具有幂等意图，但服务提供商可能为它附加电子邮件通知或异步下游操作。如果服务提供商支持幂等字段，`POST` 也可能是安全的。应阅读端点契约，并测试实际行为。

对于带幂等令牌的操作，应在第一次网络调用前创建令牌，并将它与规范化请求指纹一起持久化。每次重试都发送完全相同的令牌和逻辑上相同的载荷。401 后不要生成新令牌。新令牌会告诉服务提供商这是一次新操作，从而破坏幂等性的意义。

```json
{
  "operation_id": "job-84f3/create-draft",
  "idempotency_token": "a stable random value stored before send",
  "request_fingerprint": "method, path, normalized body hash",
  "attempt": 1,
  "authorization_generation": 17
}
```

“规范化正文哈希”这句话很重要。如果重试构建器修改了时间戳、数组顺序或生成的标签，就可能在不知不觉中让同一个幂等令牌对应到不兼容的请求。有些服务提供商会拒绝这种不匹配，另一些处理方式并不一致。应保存第一次序列化的请求正文，或只规范化一次并重复使用。

速率限制和网络重试逻辑必须共享同一个预算。代理先进行三次网络重试，再进行一次刷新重试，然后再进行三次网络重试，就创造了七次重复操作或使系统过载的机会。应定义操作级别的尝试预算。例如，安全的读取可以允许初始调用、一次刷新路径和一次重放。类似付款的操作可能只允许初始调用，然后进入协调确认。

被抑制的重试也要全部记录。操作员需要看到系统是因为 `renewal_attempted=true` 而有意停止，而不是误以为代理崩溃了。当服务提供商改变错误格式，分类器开始拒绝过去允许的恢复时，这条记录也能提供清晰信号。

## 检查点让任务恢复时不必编造过去

长期运行的代理任务应分别保存已完成的远程效果和待处理意图，因为刷新后的令牌无法告诉你失败前发生了什么。常见的错误模式只保存聊天记录或代理的最终计划，然后在中断后让代理重建状态。

应使用可由程序协调的任务日志。每次计划执行的外部调用都需要稳定的操作 ID。每个已确认结果都应包含服务提供商的资源 ID，以及可用时的版本或 ETag 和请求指纹。每个结果不确定的操作都需要记录解决它的查询规则。

一个紧凑的检查点可以这样表示：

```json
{
  "task_id": "release-2025-04-17-42",
  "completed": [
    {"operation_id": "create-change", "remote_id": "CR-819", "version": "6"},
    {"operation_id": "upload-bundle", "remote_id": "asset-552"}
  ],
  "pending": {
    "operation_id": "attach-bundle",
    "request_fingerprint": "POST /changes/CR-819/assets body-sha256:...",
    "reconcile": "list assets for CR-819 and match asset-552"
  },
  "authorization_state": "authorization_expired"
}
```

任务不需要保存每个中间思考，只需要足够的事实来确定下一步安全的远程操作。不要把机密、bearer 标头和刷新响应放进这份日志。这些材料属于凭据所有者，而不是一般任务存储。

暂停期间，版本条件仍然重要。如果任务在过期前读取了文档版本 6，过了一小时后恢复时，其他参与者可能已经修改了它。条件允许时，应使用 ETag、修订号、条件标头或服务提供商专用的并发字段。如果条件失败，就告诉代理原来的计划已经不再适用。不要因为令牌刷新成功，就让任务按照“世界属于自己”的假设覆盖更新后的状态。

自主工作需要在判断能力周围设置边界。任务可以安全地恢复一个目的地和哈希都已记录的上传操作。但在原始上下文已经过时后，它不应随意重新规划部署、修改审批记录或选择其他目标。此类操作应标记为恢复后需要重新确认。

## 恢复响应必须告诉代理它能做什么

操作网关应返回结构化的恢复结果，而不是含糊的认证句子，以免代理自行发挥。结果应告诉代理调用是否已经执行、网关是否刷新了授权、是否允许重放，以及是否需要人工介入。

有用的结果会将执行状态与凭据状态分开：

```json
{
  "operation_id": "attach-bundle",
  "execution_state": "not_sent",
  "authorization_state": "reauthorization_required",
  "retry_allowed": false,
  "credential_ref": "release-api",
  "operator_action": "Reauthorize the release-api connection, then resume task release-2025-04-17-42",
  "safe_resume_from": "attach-bundle"
}
```

`not_sent` 表示网关在请求交给网络客户端之前就停止了。`outcome_unknown` 表示它无法作出这个保证。不要把两种情况都归入 `failed`。第一种可以等待重新授权，第二种必须先向远程服务协调确认，再进行任何重试。

代理还需要一套有限的恢复词汇。可以提供 `completed`、`renewed_and_replayed`、`needs_reconciliation`、`reauthorization_required`、`permission_denied` 和 `configuration_error` 等结果。每个结果都应对应一种允许的行为。例如，代理在收到 `renewed_and_replayed` 后可以继续；收到 `needs_reconciliation` 后可以执行文档规定的只读协调查询；收到 `reauthorization_required` 后必须停止外部写入。

不要只把服务提供商的原始响应作为信号。原始细节有助于诊断，但代理可能会误解它们，尤其是在不同服务提供商使用不一致措辞时。应将原始响应保存在受保护的诊断记录中，并向调用方返回稳定的机器可读决定。

好的错误消息会指出身份引用和被阻止的操作，但不会暴露机密。“凭据 `release-api` 需要重新授权，之后才能执行 `attach-bundle`”能告诉操作员去哪里处理。“Unauthorized”则什么也没说明。

## 刷新凭据需要比访问令牌更严格的控制

刷新凭据通常比它替换的访问令牌存活更久，因此需要更强的保护。不要为了应对令牌过期，就把这种长期凭据分发到每个代理工作区、构建目录、环境变量或聊天记录中。

OAuth 2.0 Security Best Current Practice，也就是 RFC 9700，建议公共客户端使用刷新令牌轮换或发送方约束的刷新令牌。服务提供商的具体支持各不相同，但安全经验始终适用：被窃取的可刷新凭据，其被滥用的时间窗口远长于普通短期 bearer 令牌。

应将刷新材料保存在操作网关的加密凭据存储中。限制哪些操作定义可以选择它；对于需要更高风险控制的操作，要求明确的人工批准；并将重新授权设为独立的操作。锁定的凭据存储必须拒绝工作，而不是允许代理退回到配置文件中复制的机密。

对于受支持的 HTTP 和 SSH 操作，Sallyport 遵循这一模式：其加密保险库存储凭据，代理通过 MCP shim 请求操作，并且只收到结果。这种分工很有用，因为当恢复出错时，代理无法将刷新凭据打印到自己的上下文中。

应谨慎处理刷新响应。一些服务提供商会轮换刷新凭据，并在使用后使旧值失效。刷新负责人必须在释放等待中的调用之前，以原子方式替换存储的值。如果它写入了新的访问令牌，却在替换刷新凭据时失败，任务可能暂时还能工作，但会在下一次过期时永久失败。

绝不要记录以下字段：`Authorization`、访问令牌、刷新令牌、客户端机密、签名断言或完整的令牌端点正文。当系统在脱敏器运行前复制原始请求对象时，仅靠遮盖并不够。应设计日志记录器，让它接收凭据引用和令牌代次，而不是包含机密的结构。

## 人工批准应恢复权限，而不是制造重试风暴

只有当人工批准对应清晰决定时，它才有帮助：允许这次代理运行、允许这次敏感调用，或恢复已撤销的授权。过期后出现一个笼统的“重试”按钮，往往会让操作员在不清楚具体操作的情况下机械盖章。

应将各种批准分开。重新授权会为凭据所有者提供新的授权或可用的刷新路径。会话授权决定这次特定的代理进程是否可以请求操作。单次调用批准决定现在是否可以执行敏感操作。这些是不同的决定，将它们合并会造成提示过多，或形成过大的长期权限。

凭据需要重新授权时，应显示受影响的凭据引用、身份或连接标签、被阻止的操作以及任务检查点。不要显示令牌本身。操作员恢复授权后，网关只能恢复检查点中记录的待处理操作，不应悄悄重放代理聊天记录中所有失败的调用。

Sallyport 的固定决策阶梯适合这一边界：保险库锁定时拒绝所有操作；默认情况下，新代理进程需要会话批准；选定的凭据可以要求每次使用都批准。这些控制不会试图从一堆规则中推断意图，因此当过期凭据打断一次原本合法的运行时，处理会更可靠。

批准疲劳通常是设计错误。如果十个并行调用同时发现同一次过期，操作员却看到十几个提示，说明刷新协调器没有合并这个事件。应呈现一个重新授权请求，让其他调用等待，然后把最终决定报告给每个任务。

不要用批准来掩盖写入结果未知的问题。此时正确的提示应询问操作员是否希望系统协调确认远程状态，而不是是否要重试。人和代理一样，都可能误操作而造成重复。

## 过期测试必须覆盖结果不确定的写入和并发工作进程

一个在读取前返回一次模拟 401 的令牌刷新测试，几乎不能证明什么。真正伤害团队的失败往往发生在边界上：并行工作期间、写入提交后、刷新轮换时，或授权被撤销时。

应构建测试服务提供商或可控的 HTTP 测试装置，记录收到的操作 ID，并能在预先定义的节点注入失败。断言应同时检查远程效果记录和本地审计记录。仅仅看到最终响应成功，可能掩盖重复创建。

在信任长期运行的代理前，执行以下测试：

1. 在读取前令访问令牌过期。确认只有一个工作进程刷新，所有等待中的读取都使用替代代次。
2. 在幂等写入前令它过期。确认网关只刷新一次，并使用原始操作 ID 和请求正文重放。
3. 在服务提供商记录非幂等写入后丢弃响应。确认任务进入 `outcome_unknown`，执行查询，并且不会产生第二个效果。
4. 在刷新前撤销授权。确认所有相关操作都以 `reauthorization_required` 停止，并且不会循环调用令牌端点。
5. 返回轮换后的刷新凭据，然后中断存储。确认网关发现替换未完成，并阻止后续刷新，而不是使用过期副本。

要明确测试时间因素。向凭据组件注入时钟，以便把过期时间安排在请求构建前、标头构建后，以及请求在队列中等待期间。等待真实令牌过期会让测试变慢，还会漏掉重要的时间边界。

最后，在无法访问保险库的情况下测试审计路径。即使无法解密每条记录，也应该能够验证尝试调用和刷新决定的顺序没有改变。Sallyport 的 `sp audit verify` 可以在不需要保险库密钥的情况下，验证加密审计数据上的哈希链。这正是凭据访问可能仍被锁定时，事故调查所需要的验证方式。

处理过期得当的任务，在授权失败后会减少操作。它会停止，分类失败，让一个负责人刷新，协调确认不确定性，然后只恢复记录中的操作。这种克制可以避免重复副作用，并为操作员留下证据，而不是一堆重试。
