# 需求:桌面产品跨产品免登录-设备级-SSO-共享凭证层

状态: accepted(2026-10-20 转化链路②插队:一次邮箱认证后同设备免登录,现状每产品重走 magic link)
归属: pinnadash 产品
来源: boss 直投(ops/data/zhihu/index.jsonl)
日期: 2026-10-11
建议: 做,一次邮箱认证后同设备各产品应免登录,现状每产品重走 magic link,是账号体系对标业界的最大流程缺口(证据:2026-10-11 boss mac 实测反馈+当日 worklog 排查)

## 现状

boss 10-11 mac 实测反馈:登录每个产品都要重新走一遍邮箱认证,登录态没有跨产品复用。当日排查(证据锚点见 worklog 2026-10-11 12:2x 认领行)定位三层:

1. **服务端通道已有、桌面端没消费**:`account_center` 已上线 30 天中央会话 `ac_sso` cookie(auth.ts:97-114,token.ts:21)+ 免邮箱续发 code 端点 `POST /v1/auth/authorize`(auth.ts:721-755,凭 cookie **或 Bearer** 均可,`resolveSessionSub` auth.ts:56-77)。10-11 实测线上已部署(authorize 无会话返 401 非 404;mac origin `tauri://localhost` 与 `http://tauri.localhost` CORS 预检均放行,c1644c6)。
2. **但兑现路径只覆盖浏览器**:`ac_sso` 种在浏览器里,桌面 App 读不到;门户登录页命中会话也要手点「继续并回到应用」,不做静默回跳(design.md:392 明示非目标)。桌面 SDK(desktop-glue)从未检查中央会话——每个产品从本地快照空起步就是完整 magic link 流程。
3. **无设备信任记录**:服务端全仓无 devices/sessions/trusted 表,SSO JWT 无服务端登记、不可单独吊销(design.md:382 自认);"这个设备验证过一次"在系统里根本没有落点。

token 按产品隔离(access `aud=client_id`,auth.ts:597)是业界正常设计,不动;缺的是隔离层之上的**共享会话层**。对标:Google/Microsoft/1Password 桌面全家桶=首次认证后把会话凭证写进 OS 级共享凭证区(同开发者签名的 macOS Keychain access group),各 App 启动先查它、命中即调 authorize 静默换自己的 token——用户体感"登一个,全都登了"。

### 10-11 16:5x Windows 复现补证（boss 实测 Easy Image 登录后 Easy Disk 仍走邮箱 = 同一缺口,非新缺陷）

- 字节级定位「缺哪一步」：`grep -rn "auth/authorize|auth/sso" packages/sdk/src/` **0 命中**——桌面 SDK 从未消费中央会话；`desktop-glue.ts:383-390` 的 `login()` 只做 `createLoginUrl()` + `openExternal()`。各产品快照各写各的 appDataDir（Easy Image `apps/desktop/src/account.ts:10`、Easy Disk `src/account.ts:35` `invoke("read_snapshot")`，同名不同目录），产品 B 启动必然读到空快照。
- 浏览器侧一键回跳链路**已部署可用**（不是待建）：线上 `/login/` 与本地 `apps/portal/login/index.html` md5 相同（`546838231c93be33bd97be08fef49b95`），其中 `restore()→trySso()`（index.html:586/330）→「继续并回到应用」（:77）；无 cookie `POST /v1/auth/sso` 线上返 `401 "SSO session required"`。即：**当前最小体验改进已存在，只是桌面端没把它接上**。
- 白名单核对通过：`redirects.ts:47-49` easy-disk=`easydisk://auth/callback`，与 glue 推导 `${scheme}://auth/callback`（`desktop-glue.ts:199`）一致，authorize 不会因 redirect_uri 拒。
- **方案 1 可降级**：`resolveSessionSub`（auth.ts:56-77）调 `verifyAccessToken` 时不传 `audience`（token.ts:63-72 缺省即不校 aud），任一产品的合法 access token 即可作中央会话凭证为别的产品续发 code。故 `auth_device_sessions` 新表**不是 MVP 前置**——步骤 1 可先不做服务端改动，只用步骤 2/3（共享存储放现有 token + 启动静默 authorize）即可达成"登一个全都登了"。代价=没有按设备吊销的登记面，且该 aud 宽松性本身要进安全评审（步骤 1 与验收标准 2、6 保留，作为收口而非前置）。
- **17:0x 根因实证（boss 复述「Easy Disk 又让我输邮箱」后定位）——本票现状 1 要打折**：桌面产品登录的确认出口走自定义 scheme 分支（`auth.ts:341 return fallbackHtml`），而 `page()`（`auth.ts:121`）返回裸 `new Response`；Hono 4.13.7 的 `setCookie` 只写 `c.#preparedHeaders`（helper/cookie/index.js:61 → context.js:213-224），只有 `c.redirect`/`c.json`/`c.html` 经 `#newResponse`（context.js:279-281）才把 prepared headers 合进响应，`set res` 的合并分支要求 `#res` 已存在（context.js:119-121）——所以 **`ac_sso` 在五个桌面产品必经的这一支被整条丢掉**，桌面登录从未建立过中央会话。A/B 变异对照（临时脚本跑完即删）：同版 Hono 两条款出口，`c.redirect` 分支 `set-cookie=["ac_sso=jwt-here; Path=/; HttpOnly; SameSite=Lax"]`，裸 `new Response` 分支 `set-cookie=[]`；社交登录回调（auth.ts:544 用 `c.redirect`）不受影响。
- **步骤 0（新增前置 P0，服务端小刀）**：确认页深链分支改走 `c.html(...)`（或把 prepared Set-Cookie 显式附到该 Response），并加「两条出口都断言 set-cookie 存在」的回归测试。修完后的体验=第二个产品在浏览器里一次「继续并回到应用」免邮件；**桌面零点击仍需步骤 2/3**，本票其余方案不变。
- **步骤 0 已落地（10-11 17:2x，未 commit、待 deploy）**：`page()` 拆成 `renderPage()`（产字符串）+ 裸 Response 版（保留给其余 22 个不涉及 cookie 的出口，函数头写明纪律「设过 cookie 的出口一律走 `c.html`」），确认 handler 两处 set-cookie 后出口改走 `c.html(renderPage(...), status)`；回归用例先红（`expected null not to be null`，同组 302 分支断言全绿=对照组有效应）后绿，`apps/api` 全量 19 files / 214 tests 绿 + `npm run check` 绿。逐条实查其余 cookie 出口无同类丢失（302 分支与社交登录 `auth.ts:552` 用 `c.redirect`，`account.ts:97` 用 `c.json`，`portalBindRedirect` 分支不设 cookie）。deploy 后本票现状 1「服务端通道已有」才算真正成立。
- **步骤 0 已上线（10-11 18:1x，boss 点头 deploy）**：生产 worker `account-center-api-prod` 新版本 `b1ffa052-73d8-4b0e-998f-a5c8b3651e33`（`deployments status` 实读 2026-10-11T10:05:07Z、100% 流量；上一版 `ad7f5bad`（10-09 09:51Z）留作回滚锚点）。上线前三关实跑=根 `pnpm check` 绿、根 `pnpm test` 绿（api 214 / admin 63 / sdk 99）、根 `pnpm build` 三端产物 dry-run 绿；部署后验收不吃 CLI 自述，`verify:live` 6 项 + `verify:smoke` 7 项全绿。同场修掉一条让根 check 自 10-04 起一直红的闸门误报（`scripts/check-prod-config.mjs` 放行 `tauri://localhost` 这一 scheme 形态保留 origin，变异验证：注入 `http://localhost` 与 `127.0.0.1` 仍各拦一条）。**分支级实证留给 boss 真机复测**（不在生产造会话）：Easy Image 邮件确认后，用 Easy Disk 点登录应显示「继续并回到应用」且不再发第二封邮件；同浏览器新开 https://account.pinnadash.com/ 应直接是登录态。步骤 2/3（SDK 设备级共享凭证 + 启动静默 authorize）未开工，本票保持 proposed。

## 方案

分四步,可跨仓代做(account_center + packages/sdk + 五桌面产品):

1. **服务端签发设备会话**:magic-link confirm / 社交登录成功深链回产品时,除现有 code 外经 `/v1/auth/token` 附带返回 30 天 `typ:"device"` 会话 token(新 D1 表 `auth_device_sessions` 落登记,可按设备吊销——补 design.md:382 不可吊销缺口)。`resolveSessionSub` 接受 device token 作 Bearer 凭证。
2. **SDK 写共享凭证区**:desktop-glue 首次换到 token 时把 device token 写入跨产品共享存储——macOS 用同 Team ID 的 Keychain access group(`com.pinnadash.sso`,产品签名证书已有,需建 group entitlement);Windows 用 `%PROGRAMDATA%\Pinnadash\` 下 DPAPI 加密文件;Linux 用 `~/.local/share/pinnadash-sso/`(0600)。
3. **产品启动时序接静默续登**:本地快照空 → 读共享 device token → `POST /v1/auth/authorize`(Bearer device token,带 PKCE challenge)→ `/v1/auth/token` 换本产品 token → 落本地快照。全程零邮箱零点击;device token 缺失/过期才显示邮箱表单。
4. **账号切换兜底**:静默续登时 UI 显示「以 xxx@ 继续(本机已认证)」+「使用其他账号」入口;App 内登出只清本产品快照,不动设备会话(对齐现状"单点登出明确不做",design.md:380)。

改动面:account_center(api routes/store/token + 迁移 + portal 不动)、packages/sdk(desktop-glue init 流程 + 共享存储适配器)、五产品(升 SDK,无各自代码改动)。**前置**:P0 三修(ES 快照路径/EC register_all/线上核对)当日落地后,本票才有可复现的"登录态存得住"基线。

## 验收标准

- [ ] mac 全新设备登录产品 A(走一次 magic link)后,启动产品 B/C/D/E 均**不发邮件、零点击**进入已登录态(实测五产品矩阵,留 `tools/mac_login_matrix.mjs` 同款取证行)
- [ ] `auth_device_sessions` 表有该设备登记行;admin 侧吊销后,五产品下次启动全部回落邮箱流程(吊销路径可检验)
- [ ] 换账号场景可用:「使用其他账号」入口走完新邮箱流程后,其余产品续登身份切换为新账号
- [ ] App 内单产品登出不影响其他产品会话(与 design.md 单点登出非目标一致),重启本产品回落共享层仍免邮箱
- [ ] Windows/Linux 各至少一台设备复现验收标准 1(共享存储跨平台适配器生效)
- [ ] 安全评审行:device token 泄露影响面评估(30 天 + 仅 authorize 权限,不含支付/管理面)写入 design.md 新增节
