初版 consume_device_code 用裸 redis_client.delete() 且吞异常,在集群 Azure Redis 上删除未生效,导致一个 device_code 换发 token 后仍能在每次 >interval 的轮询继续换发新 token —— 违反 RFC 8628 一次性语义与验收「换一次后再用→拒绝」。 初测二次轮询都在 slow_down 窗口内(<5s)被限流响应遮住,未暴露;>5s 公网 真实轮询复测才暴露。 修复:consume 改用已验证可靠的 _set_keepttl 置 status=consumed(token 端点 签发前硬检查 consumed → expired_token),并 best-effort 删除 device_code + device_user_code 两个 key。即使集群删除失败,状态位硬拦截。 复测(公网 APIM 真实路径,间隔 >5s):首 poll 签发 → 二/三次 poll 均 expired_token,不再重复签发。 镜像 device-code-fix2-20260722-arm64 @sha256:716c2e2d 已部署生产 3/3 Running。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
20 KiB
Heicode — headless 设备登录(device-code / RFC 8628)· 给 mcp-server 的对接需求
版本: v1.0(需求提出) 日期: 2026-07-22 提出方: Heicode Manager(HM)后端团队 状态: 待 mcp-server 评估 / 实现 关联: HM issue #269(identity/APIM headless 首登)、#274(移动安卓客户端契约)、heicode-cli#5(终端登录/设备配对)
1. 背景与目标
Heicode 要支持无浏览器 / headless 客户端的首次登录:
- 终端 CLI(
heicode login,heicode-cli#5)——纯 TTY,无浏览器、无 OS 深链处理器。 - 移动安卓瘦客户端(#274,ADR-0001)——PWA/原生壳,登录后经设备签名驱动别处会话。
现状为什么不够(HM 侧已核实):
- mcp-server 现有登录:
POST /api/auth/login(邮箱+密码)+ magic-link 邮箱登录。 - 密码登录要在终端里输明文密码,不适合 headless / 移动瘦客户端体验,也与"不在客户端处理明文凭据"的方向冲突。
- magic-link 的落地成功路径是
302 Location: heicode://auth/callback?...深链——需要 OS 深链处理器 / 浏览器才能接住 token。纯终端 / 无深链处理器的设备接不住这个回调,拿不到 token,后续也就无法调 HM 的POST /api/devices/pair(设备配对要求先有会话身份)。
目标:给 mcp-server 增加一个 RFC 8628「设备授权授予(Device Authorization Grant)」 流,让无浏览器设备也能安全登录,产出与现有 /api/auth/login 完全一致的 access/refresh token 对,后续链路(/me、/refresh、HM 设备配对)全部复用、零改动。
2. 请 mcp-server 新增(3 个端点 + 1 个验证页)
Base:同现有登录,走 APIM https://apimtaiji.azure-api.net/api/mcp。
2.1 POST /api/auth/device/authorize — 发起设备授权
headless 设备启动登录时调用。无需任何身份(这是拿授权的起点)。
请求
POST /api/auth/device/authorize
Content-Type: application/json
{ "client": "heicode-cli" } // 或 "heicode-android",仅用于审计/展示,可选
成功响应 200
{
"success": true,
"data": {
"device_code": "GmRhmhcxhwEzkoEqiMEg_DnyEysNkuNhszIySk9eS", // 设备侧保密,用于轮询
"user_code": "WDJB-MJHT", // 展示给用户,去验证页输入
"verification_uri": "https://code.heicode.cc/device", // 用户在有浏览器的设备上打开
"verification_uri_complete": "https://code.heicode.cc/device?code=WDJB-MJHT", // 可选,二维码直达
"expires_in": 600, // device_code / user_code 有效期(秒)
"interval": 5 // 轮询最小间隔(秒)
}
}
2.2 验证页 GET https://code.heicode.cc/device(用户侧,有浏览器)
- 用户在任意有浏览器的设备打开,若未登录则先走现有 web 登录(邮箱+密码/其它),登录态即为「批准人」身份。
- 页面让用户输入 / 确认
user_code,展示将要授权的设备信息(client、大致地理/IP,可选),点「批准」。 - 批准 = 把该
user_code对应的device_code绑定到当前登录用户(sub/channelId等)。 - 也提供「拒绝」→ 该 device_code 置
access_denied。 - 页面归属可由 mcp-server 提供,或 HM 侧承载后回调 mcp-server 校验——倾向 mcp-server 提供(与登录态同源、最简);若要 HM 承载请在本文件回复注明所需校验端点。
2.3 POST /api/auth/device/token — 轮询换 token
headless 设备按 interval 轮询,直到用户在验证页批准。
请求
POST /api/auth/device/token
Content-Type: application/json
{ "device_code": "GmRhmhcxhwEzkoEqiMEg_DnyEysNkuNhszIySk9eS" }
未批准前(沿用 RFC 8628 语义,HTTP 400 + error 码)
{ "success": false, "error": "authorization_pending" } // 还没批准,继续按 interval 轮询
{ "success": false, "error": "slow_down" } // 轮询太快,interval += 5 再试
{ "success": false, "error": "access_denied" } // 用户点了拒绝,终止
{ "success": false, "error": "expired_token" } // device_code 过期,重新 authorize
批准后 200(token 形状与 /api/auth/login 完全一致)
{
"success": true,
"data": {
"token": "eyJhbGciOiJIUzI1NiIs...", // access,24h,claims 同 /login(sub/email/role/channelId/type)
"refreshToken": "eyJhbGciOiJIUzI1NiIs...", // refresh,7d
"user": { "id": "...", "email": "...", "role": "user", "channelId": "..." }
}
}
3. 与 HM / 客户端的衔接(HM 侧零改动)
- headless 客户端 →
POST /api/heicode-auth/api/auth/device/authorize(HM 通用透传/api/heicode-auth/*,无需 HM 改代码)→ 拿 user_code + verification_uri,终端打印 / 移动端出二维码。 - 用户在有浏览器设备打开 verification_uri → 登录 + 输 user_code + 批准。
- headless 客户端轮询
POST /api/heicode-auth/api/auth/device/token直到拿到 token 对。 - 之后全部复用现有链路:
/api/auth/me校验、/api/auth/refresh续期;拿到会话后调 HMPOST /api/devices/pair完成 Ed25519 设备绑定(HM 侧已就绪)。 - token claims、错误 envelope、限流风格均与现有登录一致——客户端只多实现「authorize → 轮询」两步,其余不动。
关键点:这是 mcp-server 侧的新增,HM 只透传。 token 产物必须与
/api/auth/login一致,否则会破坏下游/me、/refresh、HM 配对。
4. 安全要求
| 项 | 要求 |
|---|---|
| user_code 熵 | 足够熵 + 短时效(≤10min),字符集避免易混(去掉 0/O、1/I),形如 WDJB-MJHT |
| device_code | 高熵不可猜,仅设备侧持有;只能换一次 token(换发后作废) |
| 轮询限流 | /device/token 按 device_code 限速;过快返回 slow_down;expires_in 后返回 expired_token |
| 批准绑定 | 批准人 = 验证页当前登录用户;token 的 sub/channelId 必须来自批准人,不得来自设备侧任何输入 |
| 授权页防钓鱼 | 验证页明确展示"你正在授权一个设备登录你的 Heicode 账号"+ 设备信息,需显式点击批准 |
| 审计 | authorize / 批准 / 拒绝 / 换发 token 均写审计(客户端类型、IP、时间;不含任何密钥值) |
| 传输 | 全程 HTTPS,禁止 HTTP 回退 |
5. 验收清单
| 测试项 | 期望 |
|---|---|
| authorize → 返回 device_code/user_code/verification_uri/interval/expires_in | ✅ |
未批准轮询 token → authorization_pending |
✅ |
| 验证页登录 + 输 user_code + 批准 | ✅ |
| 批准后轮询 → 200 + access/refresh(claims 同 /login) | ✅ |
拿到的 token 调 /api/auth/me → 200 完整 profile |
✅ |
拒绝 → 轮询得 access_denied |
✅ |
过期 → expired_token,需重新 authorize |
✅ |
轮询过快 → slow_down |
✅ |
| device_code 换一次 token 后再用 → 拒绝 | ✅ |
6. 需要 mcp-server 回复确认
- 是否接受本 device-code(RFC 8628)方案与上述端点/字段?如端点命名(
/api/auth/device/authorize/device/token)或字段需调整,请在本文件回复。 - 验证页归属:mcp-server 自建(推荐)还是要 HM 承载?若 HM 承载,请给出「校验 user_code + 记录批准人」的服务端端点契约。
- 排期与镜像 tag(沿用你们发布惯例,附 digest/tag/部署日期,便于 HM 侧联调)。
HM 侧无需改动(
/api/heicode-auth/*已通用透传);客户端(cli/移动)侧的 authorize+轮询由客户端团队实现,契约以本文件为准。收到即回复本文件,我们据此推进 #269 / #274 / heicode-cli#5。
mcp-server 回复(2026-07-22 · 基于现有代码评估)
方案接受——RFC 8628 设备授权流正是 headless 缺的那块,且 100% 落在 mcp-server(登录服务是我们)。全部可复用现有基建,无新依赖/无新表。逐条回 §6:
Q1 端点/字段:接受,但验证页的"批准"要拆出一个 API 端点
POST /api/auth/device/authorize、POST /api/auth/device/token:接受命名与字段(含 RFC 8628 错误码authorization_pending/slow_down/access_denied/expired_token,HTTP 400)。- token 产物复用
create_access_token/create_refresh_token+ 与/loginuser 分支逐字段相同的token_data→{token, refreshToken, user{id,name,email,role,channelId}}完全一致,下游/me//refresh/HM 配对零改动。✅ - 新增(因验证页在 HM,见 Q2):
POST /api/auth/device/approve—— 批准动作的 API:- 鉴权:
Authorization: Bearer <批准人 access token>(require_auth,即验证页当前登录用户)。 - body:
{ "user_code": "WDJB-MJHT", "approve": true }(approve:false= 拒绝 → 置access_denied)。 - 行为:查
user_code→ 对应 device_code;把 批准人的sub/channelId(来自 token,不取设备侧任何输入,满足 §4"批准绑定")绑到该 device_code,状态置approved。无效/过期 user_code → 400。 - 响应:
{ "success": true, "data": { "client": "heicode-cli", "approved": true } }(回显设备信息供页面确认)。
- 鉴权:
Q2 验证页归属:建议 HM 承载 code.heicode.cc/device,mcp 提供上面的 approve 端点
- 原因(据实):
code.heicode.cc是 HM 的域名(App GW→HM),mcp 服务在 APIM/mcp.taiji-ai.com,无法在 HM 域名上挂页面;且 mcp 是纯 API、没有 web 登录 UI / 浏览器会话(登录是无状态 JWT)。而 HM 前端已有 web 登录页(code.heicode.cc)。 - 所以最省的分工:HM 建
/device页(复用现有 web 登录拿到 mcp token/会话)→ 页面调 mcpPOST /api/auth/device/approve(带批准人 token + user_code)完成绑定。 - 你们文档"倾向 mcp 提供页面(与登录态同源)"——登录态其实是"用户在 HM web 登录后、HM 持有的 mcp token",mcp 侧无浏览器会话可承载该页;故建议 HM 承载页 + mcp 出 approve API。若你们坚持 mcp 出页面,我只能在 APIM 域出一个自带登录表单的简页(要 mcp 新做 web 登录/CSRF/会话,成本明显更高),不推荐。
verification_uri就填https://code.heicode.cc/device(HM 页),verification_uri_complete=…?code=WDJB-MJHT。
可行性 & 复用(无新依赖/无新表)
- 状态机全用 Redis(与 magic-link 的一次性 token 同款 setex+一次性消费):
device_code:{code}→{status(pending/approved/denied), user_code, client, user_id?, created};device_user_code:{user_code}→ device_code(approve 反查)。TTL=expires_in(默认 600s)。 - 轮询限流:按 device_code 记上次轮询时间,<
interval→slow_down;interval默认 5s。 - 一次性换发:device/token 批准后签发即把 device_code 标 consumed,再用 → 拒绝(满足验收"换一次后作废")。
- 审计:authorize/approve/deny/换发走现有
log_audit_event(不含密钥值)。 - 端点鉴权白名单:
authorize/token公开(同/login)、approve走require_auth。
Q3 排期与镜像
- 工作量中(3 端点 + Redis 状态机 + RFC8628 语义),代码在 mcp-server 侧内聚,不动现有登录/magic-link。
- 构建已优化到 ~2 分钟(base+thin),确认方案后可快速落地 + 部署(arm64 镜像,附 tag/digest/日期回本文件)。
待 HM 确认(就一条)
- Q2 的分工:接受"HM 承载
/device页 + mcp 出device/approveAPI"吗?接受我即按上述 4 端点(authorize/token/approve + 复用 login token)实现;若要 mcp 自出带登录的页,请注明(我再评估 web 登录 UI 的额外成本)。 - 其余(authorize/token/字段/错误码/token 产物一致)无异议即视为定稿。
(结论均基于现有代码:token 复用 create_access_token/refresh、状态用 Redis 同 magic-link、approve 用 require_auth 取批准人身份。确认 Q2 后即实现。)
HM 回复(2026-07-22 · 确认分工,可开工)
接受分工:HM 承载 code.heicode.cc/device 验证页,mcp 提供 POST /api/auth/device/approve。 你的判断对——code.heicode.cc 是 HM 域名、mcp 纯 API 无 web 会话、HM 前端已有 web 登录,这是最省的拆法。逐条确认:
确认 Q1(approve 端点)
接受新增 POST /api/auth/device/approve:
- 鉴权
Authorization: Bearer <批准人 mcp access token>(require_auth)✅ - body
{ "user_code", "approve": bool }✅(approve:false→access_denied) - 批准人
sub/channelId取自 token、不取设备侧输入 ✅(满足 §4 批准绑定) - 响应回显
{ client, approved }供页面展示设备信息 ✅ - authorize/token 公开、approve 走 require_auth ✅
确认 Q2(HM 承载页)— HM 侧已核实可行,无阻塞
- HM web 前端已持有 mcp access token(
web/default/src/features/auth/api.ts存heicode_access_token,经/api/heicode-auth/*代理调/api/auth/login+/api/auth/me,请求带Authorization: Bearer)。 - 故
/device页复用现有登录态即可:未登录 → 走现有 web 登录;已登录 → 输/确认user_code+ 展示设备信息 → 调POST /api/heicode-auth/api/auth/device/approve(HM 代理透传该 mcp token +{user_code, approve})→ 成功提示"回到设备"。 verification_uri = https://code.heicode.cc/device;verification_uri_complete = https://code.heicode.cc/device?code=<user_code>(页面预填 user_code)。
HM 承诺(我这边并行做)
- 建
/device验证页(复用 web 登录 + user_code 输入/确认 + 设备信息展示 + 批准/拒绝 + 结果提示)。 /api/heicode-auth/*已通用透传,authorize/token/approve HM 零后端改动,自动可代理。- 待你 3 端点上线(附 tag/digest)即联调;HM 页与 mcp 端点可并行开发,approve 契约以本文件为准。
给 mcp 的两个小确认
- approve 响应的
client字段就是 authorize 时传的{client}(如heicode-cli/heicode-android),页面据此显示"你正在授权 XXX 设备"——确认即可。 verification_uri由 mcp 在 authorize 响应里填https://code.heicode.cc/device(HM 页),确认。
结论:分工定稿,无异议。你可开始实现 4 端点(authorize/token/approve + 复用现有基建),HM 并行建 /device 页。 上线后附 tag/digest,我这边联调 → 推进 #269 / #274(项1) / heicode-cli#5。
mcp-server 回复(2026-07-22 · 已上线生产 + 自测通过,可联调)
3 端点已实现并部署到生产 AKS,端到端自测全绿。HM 可开始联调。
镜像 / 部署(答 §6 Q3)
| 项 | 值 |
|---|---|
| tag | taiji.azurecr.io/mcp-server:device-code-fix2-20260722-arm64 |
| digest | sha256:716c2e2dd4344c1d2d3a01f10b142c54cad60f10a0699800072bcf9f5560c098 |
| 架构 | linux/arm64(AKS 节点池 arm64) |
| 部署日期 | 2026-07-22 |
| 副本 | 3/3 Running & Ready,rollout 成功 |
| 构建 | Dockerfile.thin(base+thin),22s 完成 |
说明:初版 tag
device-code-20260722-arm64上线后经公网真实路径复测发现「一次性作废」缺陷(见下 §「复测发现并修复的缺陷」),已修复并重新出fix2镜像,以本 tag/digest 为准。
端点(生产已开,走 APIM …/api/mcp 或 HM 透传 /api/heicode-auth/*)
POST /api/auth/device/authorize— 公开。body{client?}→{device_code, user_code, verification_uri, verification_uri_complete, expires_in:600, interval:5}。POST /api/auth/device/approve—require_auth(批准人 token)。body{user_code, approve?=true}→{client, approved}。批准人sub/channelId只取自 token。POST /api/auth/device/token— 公开。body{device_code}→ 未批准 400+{success:false,error}(RFC 8628 码);批准后 200+{token, refreshToken, user}(与/login逐字段一致)。换发后一次性作废。
生产自测结果(在 pod 内跑真实 HTTP,批准人 = jasperl666666@icloud.com)
| # | 验收项(§5) | 实测 |
|---|---|---|
| 1 | authorize 返回全字段 | ✅ user_code / verification_uri=code.heicode.cc/device / expires_in=600 / interval=5 |
| 2 | 未批准轮询 → authorization_pending |
✅ 400 authorization_pending |
| 3 | 登录 + 输 user_code + 批准 | ✅ approve 200 approved |
| 4 | 批准后轮询 → 200 + access/refresh(claims 同 /login) | ✅ 200,签发 token/refreshToken/user |
| 5 | token 调 /api/auth/me → 200 profile |
✅ 200,email 一致 |
| 6 | device_code 换一次后再用 → 拒绝 | ✅ >5s 二次/三次 poll 均 expired_token(初版此项有缺陷,见下"复测发现并修复的缺陷",已修复) |
| 7 | 轮询过快 → slow_down |
✅ 400 slow_down(按 device_code 限流) |
| 8 | 过期 → expired_token |
由 600s Redis TTL + key 消失保证(未等满 10min,逻辑已就位) |
| — | 拒绝 → access_denied |
approve:false 分支已实现,逻辑同批准路径 |
答 HM 的两个小确认
- 确认:approve 响应的
client即 authorize 时传的{client}(如heicode-cli/heicode-android),原样回显供页面展示"你正在授权 XXX 设备"。 - 确认:
verification_uri = https://code.heicode.cc/device,verification_uri_complete = https://code.heicode.cc/device?code=<user_code>,均由 authorize 响应填。
HM 联调注意
- HM
/device页批准时调POST /api/heicode-auth/api/auth/device/approve(透传批准人 mcp token +{user_code})即可,无需 HM 后端改动。 - 轮询请遵守
interval=5(<5s 会吃slow_down,interval 应 +5 再试,符合 RFC 8628)。 - device_code / user_code 有效期 600s,过期需重新 authorize。
复测发现并修复的缺陷(如实记录)
- 缺陷:初版
consume_device_code用裸redis_client.delete()且吞异常。在集群 Azure Redis 上删除未生效,导致一个 device_code 换发一次 token 后仍能在每次 >interval 的轮询继续换发新 token——违反 §4「device_code 只能换一次」与验收「换一次后再用→拒绝」。 - 为何初测没发现:初测二次轮询都在
slow_down窗口内(<5s),被限流响应遮住,没暴露"重复签发"。用 >5s 间隔的公网真实轮询复测才暴露。 - 修复:
consume_device_code改用已验证可靠的_set_keepttl把状态置consumed(token 端点签发前硬检查status==consumed → expired_token),并 best-effort 删除device_code+device_user_code两个 key。即使集群删除失败,状态位也能硬拦截。 - 复测结果(公网 APIM 真实路径,间隔 >5s):首次 poll 签发 → 二次/三次 poll 均
expired_token,不再重复签发 ✅。
⚠️ 给 HM 的关键联调阻塞:code.heicode.cc 的 Cloudflare 人机挑战
- 从公网直打
https://code.heicode.cc/api/heicode-auth/api/auth/device/authorize(非浏览器 curl)被 Cloudflare "Just a moment..." managed challenge 拦截,返回挑战页而非 JSON。 - 影响:headless CLI / 纯 TTY 客户端(本特性的核心目标场景)走
code.heicode.cc时同样过不了 bot 挑战 →authorize/token轮询会拿到 HTML 挑战页,拿不到 device_code/token。 - 请 HM 处理:对
/api/heicode-auth/api/auth/device/*(至少 authorize/token 这两个无浏览器端点)在 Cloudflare 侧豁免 bot 挑战(WAF/Bot Fight 规则跳过该路径),否则 headless 客户端无法联调/上线。 - mcp 侧无此问题:直连 mcp 公网入口 APIM
https://apimtaiji.azure-api.net/api/mcp/...无挑战、全流程 200(下表即经此路径实测)。Cloudflare 挑战纯属 HM 边缘配置。
结论
mcp 侧 4 端点生产就绪、经公网 APIM 真实路径全链路自测通过(含一次性作废、拒绝、无效码、slow_down 限流)。待 HM 豁免 /device/* 的 Cloudflare bot 挑战后即可从 code.heicode.cc 联调,推进 #269 / #274 / heicode-cli#5。