单点登录 SSO 的实现
SSO 的本质是「有一个所有系统都信任的认证中心」。前端的工作集中在跳转、回调与票据换取。
一、票据跳转的完整流程
三方角色先分清:
- IdP / OP —— 认证中心,真正校验用户身份、签发凭证的一方(SAML 里叫 IdP,OIDC 里叫 OP);
- SP / RP —— 各个业务系统,信任认证中心签发的凭证(OIDC 里叫 RP);
- 用户浏览器 —— 在两者之间搬运凭证。
一次典型流程:
- 用户访问业务系统 A,未登录 → 跳转到认证中心,带上回跳地址;
- 认证中心校验身份(没登录就先登录)→ 带着票据/授权码回跳到 A;
- A 的服务端拿票据去认证中心换取身份信息;
- A 建立自己的本地会话,用户开始使用;
- 用户再访问业务系统 B → 同样跳转认证中心,但认证中心已有会话,直接签发凭证回跳,用户无感知。
第 5 步就是「单点」的来源:认证中心在自己域名下维持着一份会话(通常就是 Cookie),所以从第二个系统开始不需要再输密码。
关键约束:第 3 步必须在服务端完成。 换取凭证要用到客户端密钥或需要保护的参数,绝不能放在浏览器里。这也是 SSO 前端实现里唯一不能省的后端配合。
补充说明:授权码流程应配合 PKCE,且重定向 URI 要做精确匹配——这是当前(截至 2026 年 10 月)授权码流程的安全基线要求,OAuth 2.1 把这些从「建议」变成了「必须」。
二、跳转、回调与票据换取
前端负责的是流程编排,不负责判定身份:
- 未登录时的跳转 —— 把当前地址记下来(存 sessionStorage 或编码进回跳参数),登录成功后能回到原页面;
- 回调处理 —— 从 URL 上取出票据/授权码,立刻交给服务端,不要在前端留存或解析;
- 本地会话建立 —— 服务端换回身份后下发会话 Cookie,前端据此渲染登录态;
- 过期处理 —— access 过期时静默刷新,refresh 也过期时走完整跳转重新登录。
一个常见错误是把票据留在前端处理:票据是一次性的、短时效的,且换取过程需要保密参数,交给服务端是唯一正确做法。
第三方上下文里写 Cookie,除了 SameSite=None 与 Secure,现在还要带上 Partitioned:
Set-Cookie: sid=...; HttpOnly; Secure; SameSite=None; Partitioned; Path=/; Max-Age=3600
# 少了 Secure,SameSite=None 会被浏览器直接拒绝写入——这是最常被漏的一条
三、跨域下的 Cookie 约束
这是 SSO 里最难、也最容易在方案阶段就埋雷的一环。
传统 SSO 依赖认证中心域名下的 Cookie 来识别「这个用户已经登录过」。但浏览器对第三方 Cookie 的限制已经落地(截至 2026 年 10 月,Safari 与 Firefox 已默认阻止,Chrome 仍默认允许但提供用户选择开关),这意味着:
- 在业务系统页面里用隐藏 iframe 去「静默探测」认证中心是否登录,这条路基本走不通了;
- 依赖第三方 Cookie 实现的静默刷新、无感续期会失效。
可行的替代方案:
① 顶层跳转代替隐藏 iframe。 让 Cookie 处于第一方上下文——用户会看到一次跳转,但方案仍然成立。代价是体验上多一次重定向。
② 前端持有 token。 由认证中心在回跳时把 token 交给前端,各系统自行使用。代价是 token 暴露在浏览器里,XSS 风险要自己兜住。
③ 按同站策略规划域名。 让各业务系统与认证中心处于同一站点(同 registrable domain),Cookie 就不是「第三方」了。这是从根上解决,但要求组织架构与域名规划能配合。
④ BFF 模式。 浏览器只持有本域的会话 Cookie,token 相关的交互全部由服务端完成。这是当前浏览器应用更被推荐的做法(详见《JWT 与无状态认证》)。
顺带一条容易踩的细节:如果确实要用跨站 Cookie,SameSite=None 必须搭配 Secure,否则浏览器会静默丢弃,而且不报错。
授权码流程里还有两个参数不能省。state 用来防 CSRF——发起时随机生成并存进会话,回跳后必须比对一致才能继续。nonce 用来防 id_token 重放——换到 id_token 后要校验它的 nonce 与发起时的一致。PKCE 解决的是「授权码被截获」,替代不了这两个。
四、全局登出怎么联动
「清掉自己的会话」不等于「登出」。用户点了退出,其它业务系统的会话还在——下次点进去又直接进去了,这在共享设备上是个真实的安全问题。
三种实现,各有代价:
- 回调通知(Single Logout) —— 认证中心依次通知各系统失效。最彻底,代价是要维护注册列表、要处理某个系统失联的情况;
- 短有效期 + 定期检查 —— 各系统定期向认证中心确认会话是否还有效。实现简单,代价是失效有延迟;
- 前端广播 —— 同一浏览器内通过跨标签页通信通知其它已打开的页面(详见《跨标签页通信方案汇总》)。能解决「开着好几个标签」的场景,但覆盖不到未打开的页面。
实际项目里通常是组合:广播处理当前浏览器,短有效期兜底,有强安全要求时再做回调通知。
// 登出时通知同一浏览器内其它已打开的页面
const channel = new BroadcastChannel('auth')
channel.postMessage({ type: 'logout', at: Date.now() })
channel.onmessage = ({ data }) => {
if (data.type === 'logout') clearLocalState() // 清内存、缓存与本地草稿
}
注意广播只能覆盖「当前已打开的页面」,覆盖不到其它设备或未打开的标签——所以它永远是补充手段,不是完整方案。
另外,认证中心下发的会话 Cookie 属性要设全,否则跨站场景会被静默丢弃:
Set-Cookie: sid=...; HttpOnly; Secure; SameSite=None
还有一条:退出后要清理前端状态。内存里的用户信息、缓存的接口数据、IndexedDB 里的草稿都要清掉,否则下一个用户在同一台机器上能看到残留。
一个常见错误是把票据留在前端处理:票据是一次性的、短时效的,且换取过程需要保密参数,交给服务端是唯一正确做法。前端拿到回跳里的授权码后,应立刻交给服务端,并清掉 URL 上的痕迹,避免刷新或分享时泄漏。
// 回跳后从 URL 取出授权码,立刻交给服务端换取身份,不要在前端解析
const params = new URLSearchParams(location.search)
const code = params.get('code')
if (code) {
await fetch('/api/auth/callback', { method: 'POST', body: JSON.stringify({ code }) })
// 清掉 URL 上的 code,避免被刷新或分享时泄漏
history.replaceState({}, '', location.pathname)
}
授权码只该用一次,换完要立刻从地址栏清掉,否则刷新就会被重放:
const code = new URLSearchParams(location.search).get('code')
if (code) {
await fetch('/api/sso/callback', {
method: 'POST',
credentials: 'include',
body: JSON.stringify({ code }), // 换取身份在服务端完成,前端不解析令牌
})
history.replaceState({}, '', location.pathname) // 清掉地址栏里的 code
}
五、SSO 不只管登录
「登录一次处处可用」只是表面。真正的难点在跨域身份传递,以及单点登出的同步。
回跳地址也不能直接拿来用,必须做白名单校验,否则就是开放重定向漏洞。
登出时只清自己的会话同样不够——其它系统仍持有会话,要么通知,要么依赖足够短的有效期。
隐藏 iframe 静默刷新这条路基本已经走不通:Safari 与 Firefox 默认阻止第三方 Cookie,多数默认设置下它会失效(截至 2026 年 10 月)。
SSO 的技术难点已经从「怎么登录一次」转移到了「第三方 Cookie 用不了之后怎么办」和「怎么一起登出」。
基于 OIDC 做单点登录时,流程细节以 OpenID Connect Core 为准,授权码加 PKCE 那节是重点。