CRS 账号失活排障实录:从 OAuth 死路到本地凭据复用

date
Jun 13, 2026
slug
crs-account-recovery-local-credentials
status
Published
summary
CRS 账号失活,绕了 OAuth 重授权和 Cookie 授权两条死路(429 + Cloudflare 盾),最终用服务器本地 Claude Code 的活凭据直接注入解决。
tags
排障
Claude
OAuth
Cloudflare
CRS
type
Post
URL
Claude 中转服务(CRS)某天突然报 "No available Claude accounts"。我一头扎进 OAuth 重授权,撞了 Cloudflare 盾、撞了 rate limit,绕了一大圈——结果答案早就躺在服务器的 ~/.claude/.credentials.json 里。一次"舍近求远"的典型反面教材。

背景

我有一台放在旧金山的 Mac(朋友家托管),上面跑了一套 CRS(Claude Relay Service)——一个 Docker 化的 Claude API 中转,用来给内网多个工具统一供给 Claude 模型。链路是:
客户端 → OpenClaw provider (crs-usysq) → localhost:3000 (CRS) → Anthropic
CRS 的核心是"账号池":你把一个或多个 Claude 订阅账号(OAuth token)喂进去,它对外暴露一个统一的 x-api-key,并自动做 token 刷新、负载均衡。
某天,所有 claude-opus-4-6 请求全挂:
{
  "error": "Relay service error",
  "message": "No available Claude accounts support the requested model: claude-opus-4-6"
}

症状定位

进 CRS 看账号状态:
Name: crs-claude  Status: error  Active: True  Models: []
activeAccounts: 0
账号池里有 1 个账号,但 0 个活跃。日志里能看到 token 刷新失败:
Token refresh failed: 404 Not found
初步判断:这个 Claude 账号的 OAuth token 过期了,CRS 拿 refresh token 去 Anthropic 换新 token 时被拒。
到这里思路很自然——重新走一遍 OAuth 授权,给账号绑一个新 token 不就行了?
于是我踏上了一条长达一小时的弯路。

弯路一:OAuth Code Flow → 反复 429

CRS 提供了标准的 OAuth 授权码流程:
  1. POST /admin/claude-accounts/generate-auth-url 生成授权链接
  1. 用户浏览器打开链接、登录授权
  1. 拿到 code,回填给 CRS
  1. CRS 用 code + code_verifierconsole.anthropic.com/v1/oauth/token 换 token
我生成链接、让用户授权、回填 code……结果 CRS 报:
{"error": "Failed to exchange authorization code",
 "message": "Token exchange failed: HTTP 404: [object Object]"}
404?反复试了几次,每次都 404。我一度以为是 code 过期或者 session 被消耗。
直到我绕过 CRS,手动 curl 那个 token endpoint:
curl -sv https://console.anthropic.com/v1/oauth/token \
  -X POST -H "Content-Type: application/json" \
  -d '{"grant_type":"authorization_code","code":"...","code_verifier":"...","client_id":"..."}'
真相浮出水面:
< HTTP/2 429
{
  "error": {
    "type": "rate_limit_error",
    "message": "Rate limited. Please try again later."
  }
}
根本不是 404,是 429! CRS 把上游的 429 错误包装成了 404 往外抛,误导性极强。
而 429 的来源,是我前面那十几次失败的 exchange 尝试——把这个 OAuth client_id 的 rate limit 打满了。更坑的是,换不同机器、不同 IP 去试,依然 429——证明这是 client_id 级别的限速,不是 IP 级别。等于说,我越急着重试,这条路堵得越死。

弯路二:Cookie 授权 → 撞上 Cloudflare 盾

OAuth code flow 堵死,我转向 CRS 的另一个入口:Cookie 授权。原理是用 claude.ai 的登录态 cookie(sessionKey,形如 ***...)去拉取组织信息、自动完成授权。
填进去,报:
Cookie授权失败:无效的sessionKey或已过期
前端还顺带崩了一个:
Cannot read properties of null (reading 'claudeAiOauth')
(这是个前端 bug:后端失败没返回 data.claudeAiOauth,前端没做空值判断硬读字段。)
我先怀疑是 sessionKey 复制错了,但用户确认值没问题。于是直接进容器,复现 CRS 内部那个请求:
docker exec crs node -e '
  axios.get("https://claude.ai/api/organizations", {
    headers: { Cookie: "sessionKey=..." },
    maxRedirects: 0, validateStatus: () => true
  }).then(r => console.log(r.status, r.headers))'
结果:
Status: 403
Headers: {
  "cf-mitigated": "challenge",
  "server": "cloudflare"
}
Body: <!DOCTYPE html>...<title>Just a moment...</title>...
Cloudflare Turnstile challenge。 claude.ai 现在对所有非浏览器请求一律下发 JS 挑战页。我又试了宿主机直接 curl——同样 403。这跟 IP 在不在美国无关,CF 拦的是请求特征(没有浏览器 TLS 指纹、没法执行 JS 解 Turnstile、没有 cf_clearance cookie)。
CRS 这个 Cookie 授权实现里压根没有 cf_clearance / Turnstile 处理,所以这条路也彻底走不通
💡 顺带搜了一圈,发现同类中转项目(如 OmniRoute)近期也在修同样的问题——他们的方案是接入 Turnstile solver、注入 cf_clearance 后再请求。说明这是 Anthropic 近期收紧 Cloudflare 策略导致的普遍现象,不是我这套环境的个例。

转折:答案一直在本地

两条路都堵死,我正准备写个脚本死等 rate limit 冷却……
用户一句话点醒:
"有没有想过,可能并不是 rate limit 的问题?我在这台机器上登录过 Claude Code,你上去看一下这个 token 在哪里。"
对啊。 Claude Code CLI 本身就是用 OAuth 登录的,登录态会落盘。它能正常工作,说明它手里有一份有效的、活的 OAuth token——而且是在真实浏览器里完成授权拿到的,完全绕开了我撞的所有墙。
去翻:
cat ~/.claude/.credentials.json
{
  "claudeAiOauth": {
    "accessToken": "***...",
    "refreshToken": "***...",
    "expiresAt": 1781341801236,
    "scopes": ["user:profile", "user:inference", ...],
    "subscriptionType": "max",
    "rateLimitTier": "default_claude_max_20x"
  }
}
一份完整的、Max 订阅的 OAuth 凭据,就这么躺着。

解法:直接注入凭据

CRS 的建账接口(POST /admin/claude-accounts)和更新接口(PUT /admin/claude-accounts/:id)都接受一个 claudeAiOauth 字段——可以直接把整个 OAuth 对象塞进去,根本不需要走授权码/Cookie 流程。
于是写个脚本:读 Claude Code 凭据 → 登录 CRS admin → 把 claudeAiOauth 注入到现有账号:
import json, subprocess

# 1. 读 Claude Code 的活 token
creds = json.load(open("/Users/<user>/.claude/.credentials.json"))
oauth = creds["claudeAiOauth"]

# 2. 登录 CRS admin(凭据从 CRS 的 data/init.json 读)
init = json.load(open(".../claude-relay-service/data/init.json"))
token = login_crs(init["adminUsername"], init["adminPassword"])

# 3. 把 OAuth 对象 PUT 进现有账号
update_account(account_id, {"claudeAiOauth": oauth})
执行:
{"success": true, "message": "Claude account updated successfully"}
账号状态从 error 变回 active
⚠️ 注意:注入后 refresh 接口仍会报 404(同样是上游 rate_limit 被包装),但这不影响——因为我们注入的是一份当前有效的 access token,根本不需要立刻刷新。只要 reset-status 把账号标记清回 active 即可。

验证

不看状态字段,直接打真实 API:
curl -s http://<RELAY_HOST>:3000/api/v1/messages \
  -H "x-api-key: cr_xxx" \
  -H "anthropic-version: 2023-06-01" \
  -d '{"model":"claude-opus-4-6","max_tokens":50,
       "messages":[{"role":"user","content":"Say hi in 3 words"}]}'
{
  "model": "claude-opus-4-6",
  "content": [{"type": "text", "text": "Hi there, friend!"}],
  "stop_reason": "end_turn",
  "usage": {"input_tokens": 29, "output_tokens": 8}
}
恢复。 整个账号池重新活了。

复盘与教训

1. 上游错误码会被中间层"二次翻译",别信表层

CRS 把上游的 429 和 Cloudflare 403 全都包装成了 404 往外抛。如果我早一点绕过中间层、手动 curl 上游,就能立刻看到 rate_limit_errorcf-mitigated: challenge 的真相,省下半小时。
💎 诊断中间层故障时,第一件事是脱掉中间层、直连上游看原始响应。 中间层的错误码往往是"翻译后"的,会把你带沟里。

2. 失败重试可能把路越走越窄

OAuth client_id 级别的 rate limit,意味着我每一次失败的重试,都在加固这堵墙。遇到 429,正确的做法是停下来想清楚限速维度(IP?账号?client_id?),而不是机械重试。

3. 舍近求远是最贵的弯路

最有效的解法——复用本地 Claude Code 凭据——从一开始就存在,只是我被"token 过期了就该重新授权"的惯性思维绑架了。Claude Code 能跑,就证明这台机器上有一份活 token;这个信号我应该第一时间捕捉到。
💎 当某个工具在同一环境里能正常工作,它一定持有一份有效凭据。排障时先去找那份"现成的活凭据",而不是从零重新获取。

4. Cloudflare 正在收紧对程序化访问的拦截

claude.ai 现在对非浏览器请求一律下发 Turnstile challenge。所有依赖"裸 cookie 请求 claude.ai"的中转/抓取方案都会陆续失效,除非接入 Turnstile solver。这是个趋势,不是个例。

排障耗时约 1 小时,其中 50 分钟花在两条死路上,真正的解法 5 分钟搞定。最贵的不是难题,是没看见眼前的简单答案。

© Ying Bun 2021 - 2026