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) → AnthropicCRS 的核心是"账号池":你把一个或多个 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 授权码流程:
POST /admin/claude-accounts/generate-auth-url生成授权链接
- 用户浏览器打开链接、登录授权
- 拿到
code,回填给 CRS
- CRS 用
code+code_verifier去console.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_error 和 cf-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 分钟搞定。最贵的不是难题,是没看见眼前的简单答案。