Agent 工程实习复盘 17|跨端登录如何避免用户串线:SSO Token Exchange 与身份映射
本文基于一次外部 SSO 接入和跨端身份统一改造整理。文中使用外部身份服务、本地认证服务和 Agent 系统等通用名称,不涉及具体公司、账号、域名、密钥或真实用户信息。
第 12 篇讨论的是登录后如何加密保留上游 Access Token 供权益服务调用;本文只讨论登录本身:如何验证外部 Token、映射本地用户、签发本地 Cookie Session,并避免跨端用户串线。
一、先说结论
外部 SSO Token 不能直接当成本地 Agent 系统的登录态使用。
原因是本地系统还要处理自己的 Session、Cookie、Thread 所有权、用户配置和跨端访问;外部 Token 的格式、有效期和可用范围都不应渗透到每一个本地接口。因此需要一个明确的交换过程:外部 Token 只用于一次服务端身份校验,校验成功后映射到稳定的本地用户,再签发本地 Cookie Session。
真正难的部分不在“拿 Token 换 Cookie”,而在身份映射:同一个人从 Web、移动端、桥接端进入时,必须落到同一个本地 user_id;不同的人即使昵称或邮箱相似,也不能被错误合并。
二、问题背景:为什么会出现跨端用户串线
一个 Agent 系统通常把 user_id 作为许多数据的顶层边界:
- Thread 所有权和会话列表。
- 沙箱、上传目录和 Artifact 访问。
- 用户级记忆、技能启用状态和模型偏好。
- 会员权益、免费额度和 Token 用量归属。
如果同一外部用户在不同端被映射成两个本地 user_id,会看到“自己在手机上聊过,网页上却没有历史”;如果两个外部用户被错误合并成一个本地 user_id,问题就从体验缺陷变成权限事故。
常见的错误做法是用显示名或 email 直接合并:
- 显示名不唯一,而且可随时修改。
- email 可能为空、未验证、迁移变化,或在不同认证体系中含义不同。
- 某些端只提供登录账号或用户名,另一些端提供外部内部 ID。
所以要先区分“稳定的外部主体标识”和“可展示的用户资料”。
三、身份映射的优先级:外部 ID 是主键,username 是受控兼容回退
本地维护一张 SSO identity link:记录身份提供方、外部用户 ID、本地 user_id,以及用户名、展示资料和最近登录时间等辅助信息。
映射逻辑按下面顺序执行:
| 优先级 | 条件 | 动作 | 原因 |
|---|---|---|---|
| 1 | 已存在 provider + external_user_id 绑定 | 直接复用对应本地 user_id | 最稳定、最明确的身份关系 |
| 2 | 没有绑定,但上游明确提供 username,且本地已有相同 username | 复用该本地 user_id | 支持不同端使用统一登录名进入同一用户 |
| 3 | 都不满足 | 创建新的本地受管用户 | 不做猜测性合并 |
这里的 username 回退有两个限制:
- 必须是上游明确返回的 username,不能拿外部 ID 自动拼一个候选值后再用它匹配旧用户。
- username 会先做规范化和安全字符处理,避免不同客户端的大小写、空白或非法字符造成不一致。
email 和 display name 可以更新本地用户资料,但不作为自动合并两个账户的依据。这个取舍宁可造成少量重复账户,也不冒险让两个真实用户共享 Agent 数据。
graph TD;
A[外部 Token 校验成功];
B[得到 provider external_user_id username];
C{已存在外部身份绑定};
D[复用绑定的本地 user_id];
E{上游明确提供 username};
F{本地存在同 username 用户};
G[复用该本地 user_id];
H[创建新的本地受管用户];
I[更新身份绑定和用户资料];
A --> B;
B --> C;
C -->|是| D;
C -->|否| E;
E -->|是| F;
E -->|否| H;
F -->|是| G;
F -->|否| H;
D --> I;
G --> I;
H --> I;
四、完整 Token Exchange 链路
外部 Token 的验证必须在服务端完成。浏览器或移动端将 Token 发给本地认证接口后,本地服务使用受保护的服务端凭证向外部身份服务验证它;不能相信客户端自己解析出来的用户信息。
graph TD;
A[客户端提交外部 SSO Token];
B[本地 Exchange API 校验请求格式];
C[服务端调用外部身份校验接口];
D{Token 有效且返回可用主体};
E[返回认证失败];
F[解析外部身份并映射本地 user_id];
G[更新 SSO identity link 和用户资料];
H[生成本地受管登录凭证];
I[本地认证服务签发 Session Cookie];
J[响应附带全部 Set Cookie];
K[客户端后续只携带本地 Cookie];
A --> B;
B --> C;
C --> D;
D -->|否| E;
D -->|是| F;
F --> G;
G --> H;
H --> I;
I --> J;
J --> K;
这个接口有几个实际约束:
- Token 校验请求设置超时,外部身份服务异常不能无限占住登录请求。
- 外部返回体必须经过结构化解析,缺少可用外部用户 ID 就视为失败。
- 登录成功后将本地认证服务产生的全部
Set-Cookie头原样附带到响应,不能只取一个 Cookie。 - redirect 地址必须经过本地白名单或安全路径校验,不能把登录成功后的跳转变成开放重定向。
五、为什么要创建本地受管用户,而不是每次都透传外部 Token
本地 Agent 系统通常已经有一套成熟的认证中间件:它从 Cookie Session 中得到本地 user_id,再执行 Thread 所有权检查、上传权限、设置读取和网关路由。
如果每个 API 都改成验证外部 Token,会产生三个问题:
- 所有 Gateway 接口都与外部身份服务耦合,故障面和延迟增加。
- 外部 Token 的过期、刷新和权限语义会散落在每一个业务模块。
- 本地 Session、分享链接、内部服务调用和浏览器 Cookie 无法统一。
因此 Token Exchange 只在登录入口做一次,之后使用本地认证服务的标准 Session。为了让本地认证服务能签出这个 Session,系统会为已验证的外部主体维护一个内部受管凭证。它是服务端实现细节,不会返回给客户端,也不等于给用户开放密码登录。
graph LR;
A[外部身份体系];
B[一次 Token Exchange];
C[本地 user_id 和受管凭证];
D[本地 Cookie Session];
E[Gateway 认证中间件];
F[Thread 沙箱 上传 记忆等业务边界];
A --> B;
B --> C;
C --> D;
D --> E;
E --> F;
这样可以把外部身份接入收敛在一个小模块中,业务接口继续只关心本地 user_id。对于 Agent 系统而言,这种收敛尤其重要,因为 user_id 会贯穿 Runtime、文件系统和执行隔离边界。
六、身份资料可以更新,身份主键不能漂移
每次 SSO 登录后,本地会同步用户名、展示名、头像、联系方式等资料,并更新最近登录时间。这些字段的目的是改善用户体验和后台管理。
但同步资料不代表重写身份主键。已有 identity link 的 provider + external_user_id -> local user_id 映射必须保持稳定;外部用户改昵称、换头像或更新邮箱,不应该让其历史 Thread 换到另一个本地用户下。
这个区分也解释了为什么 username 只用于没有 identity link 时的受控跨端关联。一旦已有绑定,后续登录始终以外部 ID 作为真实主体,而不是反复按可变化资料重新匹配。
七、Cookie 传递是 Token Exchange 最容易被忽略的最后一步
服务端成功生成 Session,不代表浏览器真的登录成功。
认证库通常返回一个或多个 Set-Cookie 响应头。Exchange API 如果只返回 JSON 中的成功状态,却没有把这些响应头附带给浏览器,客户端下一次请求仍然没有本地 Session,看起来像“登录接口 200 但始终未登录”。
因此接口需要做两件事:
- 在 HTTP 响应头中逐个追加认证库产生的
Set-Cookie。 - 响应体可以保留桥接兼容所需的最小结果字段,但浏览器登录语义仍以标准响应头为准。
客户端不应该依赖 JSON 字段自行拼接 Cookie。真正生效的登录态必须由浏览器处理标准 Set-Cookie 头。
八、和第 12 篇的 Token 留存有什么关系
SSO 登录后,外部 Token 有两个完全不同的用途:
| 用途 | 本文是否展开 | 关键边界 |
|---|---|---|
| 验证外部身份并换取本地 Session | 是 | 一次性校验、稳定映射、Cookie 生效 |
| 后续调用外部权益服务 | 否,见第 12 篇 | 服务端加密保存、按需解密、Token 过期要求重新登录 |
不能因为系统保留了上游 Token,就让浏览器后续继续拿着它访问所有本地 API。浏览器应只使用本地 Cookie;上游 Token 留在服务端,是受限的后端集成凭证。
九、验证:重点是身份稳定性和失败闭环
相关测试和验证应覆盖以下情况:
| 场景 | 期望结果 |
|---|---|
| 外部 Token 有效 | 返回本地 Session Cookie 和安全跳转路径 |
| 外部 Token 无效或外部服务拒绝 | 返回认证失败,不创建本地 Session |
| 请求缺少 Token | 在参数校验阶段拒绝 |
已有 provider + external_user_id 绑定 | 始终返回同一个本地 user_id |
| 没有绑定但 username 匹配已有用户 | 复用该用户而非创建重复账户 |
| 上游未提供 username | 不使用候选 fallback 合并,只创建或使用明确绑定 |
| 用户更新展示资料 | 更新 profile,不改变既有身份绑定 |
| 认证库返回多个 Cookie | Exchange 响应完整附带 |
| redirect 参数异常 | 收敛到安全的本地路径 |
前端路由测试验证 Token Exchange 的参数校验、成功时的 Cookie 转发和上游拒绝处理;服务层测试验证外部 Token 校验后,Token、身份链接和本地用户解析使用同一个 resolved user_id。
十、几个看起来简单、实际风险很高的方案
10.1 每个业务接口都透传外部 Token
会让外部身份服务成为所有业务路径的强依赖,也会把 Token 过期和刷新逻辑散到 Gateway 的每个模块。
10.2 用 email 自动合并账户
email 不是所有身份体系中的稳定主键。错误合并的后果是一个用户看到另一个用户的 Thread 和文件,比重复创建一个本地账户严重得多。
10.3 用 display name 识别同一个人
昵称天然不唯一,也不适合做身份字段。它只能用于展示。
10.4 外部 Token 校验通过就返回 JSON 成功
如果没有把认证库生成的 Set-Cookie 返回给浏览器,下一次请求仍然未登录。这类问题很像前端缓存错误,实际是 HTTP 响应头没有正确传递。
10.5 每次登录都根据最新资料重算本地身份
用户名、头像和邮箱可能变化。已有外部 ID 绑定后,应该保持本地 user_id 稳定,资料更新与主体身份分离。
十一、面试时我会怎么讲
我会这样概括:
我参与过外部 SSO 到本地 Agent 系统的 Token Exchange 接入。外部 Token 只在服务端校验一次,校验后通过 provider 和 external_user_id 找到稳定的 identity link;没有绑定时,只有上游明确提供的 username 才作为跨端复用本地用户的兼容条件,避免用 email 或昵称误合并。解析出本地 user_id 后,系统创建或复用受管凭证,通过本地认证库签发 Cookie Session,并完整转发 Set-Cookie。这样后续 Gateway、Thread 所有权、上传和沙箱都只依赖统一的本地 user_id,跨端不会产生两份会话,也不会因身份误映射发生串线。
结语
SSO 接入不是在登录页多加一个按钮。它连接的是外部身份体系和 Agent 系统内部最重要的安全边界。
把 Token 校验、身份映射、资料同步和 Cookie 签发拆开,并让外部 ID 成为稳定主键,才能让用户在不同客户端进入同一个 Agent 工作空间,同时避免把“方便登录”变成“用户串线”。