Agent 工程实习复盘 17|跨端登录如何避免用户串线:SSO Token Exchange 与身份映射

0 阅读10分钟

Agent 工程实习复盘 17|跨端登录如何避免用户串线:SSO Token Exchange 与身份映射

本文基于一次外部 SSO 接入和跨端身份统一改造整理。文中使用外部身份服务、本地认证服务和 Agent 系统等通用名称,不涉及具体公司、账号、域名、密钥或真实用户信息。

第 12 篇讨论的是登录后如何加密保留上游 Access Token 供权益服务调用;本文只讨论登录本身:如何验证外部 Token、映射本地用户、签发本地 Cookie Session,并避免跨端用户串线。

17.png

一、先说结论

外部 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 回退有两个限制:

  1. 必须是上游明确返回的 username,不能拿外部 ID 自动拼一个候选值后再用它匹配旧用户。
  2. 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,会产生三个问题:

  1. 所有 Gateway 接口都与外部身份服务耦合,故障面和延迟增加。
  2. 外部 Token 的过期、刷新和权限语义会散落在每一个业务模块。
  3. 本地 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 但始终未登录”。

因此接口需要做两件事:

  1. 在 HTTP 响应头中逐个追加认证库产生的 Set-Cookie
  2. 响应体可以保留桥接兼容所需的最小结果字段,但浏览器登录语义仍以标准响应头为准。

客户端不应该依赖 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,不改变既有身份绑定
认证库返回多个 CookieExchange 响应完整附带
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 工作空间,同时避免把“方便登录”变成“用户串线”。