第三方 SSO 接入实践:redirect_uri 编码、回调一致性与跨项目联调

背景

最近在一次多前端项目的登录体系改造中,我们将原有的本地登录流程接入到了一个第三方统一认证平台。

这次改造涉及两个前端项目:

  • 门户项目:提供公开首页,未登录用户也可以访问首页内容。
  • 主业务项目:承载业务功能,访问受保护页面时必须登录。
  • 登录中转页:复用原有登录页路由,作为第三方认证回跳后的中转页面。

改造目标是:

  • 门户首页保持匿名可访问。
  • 门户首页的"登录 / 注册"入口跳转第三方认证平台。
  • 主业务项目未登录访问业务页面时跳转第三方认证平台。
  • 第三方认证完成后回跳登录中转页。
  • 中转页使用 code 换取业务 token。
  • 登录成功后回跳到原始业务页面。
  • 退出时同时清理业务系统登录态和第三方认证态。

这类接入表面看只是"跳转登录",但真正容易出问题的地方集中在四个点:

  1. redirect_uri 多层嵌套时如何编码。
  2. 发起登录和换 token 时传给业务后端的 redirectUrl 是否一致。
  3. SSO 登录跳转应该使用浏览器顶层导航,而不是 AJAX/fetch。
  4. 只有线上白名单回调地址时,本地如何跨项目联调。

整体登录链路

整体流程可以抽象为:

sequenceDiagram participant Portal as 门户首页 participant Admin as 主业务系统 participant Callback as 登录中转页 participant Backend as 业务后端 participant SSO as 第三方认证平台 Portal->>Backend: 请求获取认证跳转地址 redirectUrl Backend->>SSO: 构造第三方认证地址 Portal->>SSO: 浏览器顶层跳转 SSO->>Callback: 回跳 code + tenant_id + redirect_uri Callback->>Backend: code + tenantId + redirectUrl 换 token Backend->>SSO: 校验 code 与 redirect_uri Backend-->>Callback: 返回业务 token Callback->>Portal: 回跳原始页面

主业务系统未登录时也是类似流程,只是发起登录的入口从门户首页变成了路由守卫。


接口职责抽象

为了脱敏,下面使用抽象接口描述。

获取第三方认证跳转地址

bash 复制代码
GET /api/sso/login/getRedirectUrl

前端传入:

perl 复制代码
{
  "redirectUrl": "http://callback.example.com/login/?redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fportal%2F"
}

这个 redirectUrl 表示:第三方认证完成后,应该回跳到哪个登录中转页。

其中 redirect_uri 是业务最终希望回到的页面。

也就是说,这里有两层跳转:

rust 复制代码
第三方认证平台
  -> 登录中转页
    -> 原始业务页面

登录中转页换 token

bash 复制代码
POST /api/sso/login/login

前端传入:

perl 复制代码
{
  "code": "xxx",
  "tenantId": "xxx",
  "redirectUrl": "http://callback.example.com/login/?redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fportal%2F"
}

注意这里的 redirectUrl 需要和第一步 getRedirectUrl 阶段保持一致。

第三方认证平台在换 token 时,会校验:

  • 第一次发起认证时使用的 redirect_uri
  • 第二次使用 code 换 token 时传入的 redirect_uri

如果两次不一致,即使 code 是真实有效的,也可能换 token 失败。

退出登录

bash 复制代码
GET /api/sso/login/logout

退出时需要同时处理两类登录态:

  • 业务系统自己的 token / cookie / 用户信息
  • 第三方认证平台的登录态

如果只清理业务系统登录态,没有退出第三方认证平台,下次进入登录流程时可能会被第三方平台静默登录回来。


redirect_uri 为什么容易出问题

SSO 接入里最容易踩坑的是:

URL 作为另一个 URL 的 query 参数继续传递。

例如:

bash 复制代码
https://sso.example.com/login?redirect_uri=http://callback.example.com/login/?redirect_uri=http://localhost:3000/portal/

这个地址看起来可读,但实际上有明显问题。

外层 URL 的 query 是:

bash 复制代码
redirect_uri=http://callback.example.com/login/?redirect_uri=http://localhost:3000/portal/

如果内层 URL 继续携带 ?&=,浏览器、后端框架或第三方平台在解析时,就可能把它们当成外层参数的一部分。

更复杂一点,如果旧登录体系里还残留了这些参数:

ini 复制代码
client_id=xxx
scope=xxx

那么最终可能变成:

bash 复制代码
https://sso.example.com/login?redirect_uri=http://callback.example.com/login/?redirect_uri=http://localhost:3000/portal/&client_id=old-client&scope=xxx

这时 client_idscope 到底属于中转页,还是属于第三方认证平台,就会变得混乱。

结果可能是:

  • redirect_uri 被截断。
  • 回跳地址参数丢失。
  • 第三方平台回跳到了错误页面。
  • code 换 token 时提示 redirect_uri 不一致。
  • 前端登录成功后无法回到原始页面。

什么情况下前端需要 encode

判断是否需要 encode,不要靠感觉,应该看 URL 的嵌套层级。

前端需要 encode 的典型情况是:

  • 某个参数值本身是一个完整 URL。
  • 这个 URL 会作为另一个 URL 的 query 参数。
  • 这个 URL 内部包含 ?&= 等特殊字符。
  • 后端只是简单字符串拼接,而不是使用标准 URL 工具构造参数。

例如业务最终要回到:

bash 复制代码
http://localhost:3000/portal/

登录中转页是:

arduino 复制代码
http://callback.example.com/login/

那么前端传给后端的 redirectUrl 可以构造为:

perl 复制代码
http://callback.example.com/login/?redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fportal%2F

也就是只把内层业务页面地址 encode:

javascript 复制代码
const redirectUrl = `${callbackUrl}?redirect_uri=${encodeURIComponent(targetUrl)}`

这样中转页收到回跳后,可以从 redirect_uri 里还原出真实业务目标页。


什么情况下不需要整体 encode

不是所有场景都要把整个 redirectUrl 再 encode 一层。

如果前端传给后端的是:

perl 复制代码
http://callback.example.com/login/?redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fportal%2F

并且:

  • 内层业务目标地址已经 encode。
  • 外层中转页没有继续拼接裸露的 &client_id&scope 等参数。
  • 后端能正确把这个值作为一个完整参数处理。
  • 第三方平台最终收到的 redirect_uri 是标准编码后的地址。

那么通常不需要前端再对整个 redirectUrl 做一次 encodeURIComponent

也就是说,不推荐无脑写成:

scss 复制代码
encodeURIComponent(callbackUrlWithRedirectUri)

因为这可能会让编码层级变复杂,排查时很难判断当前字符串到底被 encode 了几次。

更稳妥的原则是:

哪一层 URL 被当作 query 参数传递,就编码哪一层的值。不要多编,也不要漏编。


什么时候可能需要整体 encode

如果后端当前实现是裸拼第三方认证地址,例如:

arduino 复制代码
ssoUrl + "?redirect_uri=" + 前端传入的redirectUrl

而不是使用类似 URLSearchParams 的方式构造参数,那么当前端传入的 redirectUrl 内部存在未编码的 ?& 时,就可能破坏外层 URL 结构。

这种情况下有两种处理方式。

第一种,推荐后端修正:

csharp 复制代码
const url = new URL(ssoUrl)
url.searchParams.set('redirect_uri', redirectUrl)

第二种,如果短期无法改后端,前端可以临时整体 encode:

ini 复制代码
const encodedRedirectUrl = encodeURIComponent(callbackUrlWithRedirectUri)

但这种方式需要前后端明确约定:后端不能重复 encode,也不能错误 decode 后再裸拼。

否则很容易出现:

  • 一次编码不够。
  • 两次编码过度。
  • 本地看似正常,第三方平台校验失败。

所以整体 encode 更像是兼容后端裸拼实现的临时方案,不应该成为长期默认方案。


顶层跳转与 CORS 问题

获取第三方登录地址时,很容易写成 AJAX 请求:

csharp 复制代码
await fetch('/api/sso/login/getRedirectUrl')

如果后端返回的是第三方认证平台地址,甚至直接返回 302,那么前端用 fetch 调用就可能遇到 CORS 问题。

这是因为:

  • AJAX 请求受浏览器跨域策略限制。
  • 第三方登录页通常不会给业务前端配置 CORS。
  • 登录跳转本质上应该是浏览器导航,不是接口取数。

更合理的方式是使用顶层跳转:

ini 复制代码
window.location.href = loginRedirectUrl

或者如果后端接口本身会 302 到第三方平台,也可以直接跳到后端接口地址:

ini 复制代码
window.location.href = `/api/sso/login/getRedirectUrl?redirectUrl=${redirectUrl}`

原则是:

SSO 登录跳转应该交给浏览器地址栏导航,不要用 AJAX 承接第三方登录页。


redirectUrl 前后一致性

这次接入里,一个关键问题是:第三方认证平台在登录校验时,会比对前后两次 redirectUrl

第一次是发起登录时:

perl 复制代码
GET /api/sso/login/getRedirectUrl?redirectUrl=http://callback.example.com/login/?redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fportal%2F

第二次是中转页拿到 code 后换 token 时:

perl 复制代码
{
  "code": "xxx",
  "tenantId": "xxx",
  "redirectUrl": "http://callback.example.com/login/?redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fportal%2F"
}

如果第一步使用的是:

perl 复制代码
http://callback.example.com/login/?redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fportal%2F

第二步却传:

arduino 复制代码
http://callback.example.com/login/

就可能导致换 token 失败。

因为从第三方认证平台视角看,这两次 redirect_uri 并不是同一个地址。

因此,中转页不能只关心 code,还需要根据当前回跳地址还原出本次登录使用的 redirectUrl,并在调用登录接口时一起传给后端。


登录中转页设计

第三方认证平台回跳后,用户会进入登录中转页。

这个页面不应该继续展示原来的账号密码登录表单,因为此时用户已经完成第三方认证,中转页的职责只是技术处理。

中转页应该做这些事:

  1. 读取 URL 上的 code
  2. 读取 URL 上的 tenant_id
  3. tenant_id 转换为后端需要的 tenantId
  4. 从当前地址中还原本次登录使用的 redirectUrl
  5. 调用后端登录接口换取业务 token。
  6. 写入本地登录态。
  7. 跳回原始业务页面。

页面 UI 可以非常简单:

  • 白底。
  • 居中 loading。
  • 展示"正在登录中..."。
  • 不展示账号密码登录框。
  • 异常时提示登录失败,并允许重新登录。

这样用户不会误以为自己还需要再次输入账号密码。


退出链路

退出链路也要区分两层状态。

sequenceDiagram participant Page as 前端页面 participant Backend as 业务后端 participant SSO as 第三方认证平台 Page->>Backend: 调用业务系统 logout Page->>Backend: 调用第三方认证 logout 代理接口 Backend->>SSO: 清理第三方认证态 Page->>Page: 清理本地 token 和用户信息 Page->>Page: 回到首页或登录入口

如果只调用业务系统 logout,可能出现:

  1. 前端清掉了本地 token。
  2. 用户再次点击登录。
  3. 浏览器跳到第三方认证平台。
  4. 第三方认证平台发现自己仍然是登录态。
  5. 用户被静默登录回来。

这不一定是 bug,但如果产品预期是"彻底退出",就必须调用第三方认证平台的登出能力。


只有白名单地址时如何本地联调

很多第三方认证平台只允许配置固定回调地址,比如:

arduino 复制代码
http://callback.example.com/login/

而本地开发地址通常是:

bash 复制代码
http://localhost:8080/login/

第三方平台不接受 localhost 作为顶层 redirect_uri,这时不能直接把本地地址传给第三方平台。

比较实用的方案是:使用代理工具把白名单域名代理到本地服务。

例如:

ruby 复制代码
http://callback.example.com/login/  ->  http://127.0.0.1:8080/login/
http://callback.example.com/portal/ ->  http://127.0.0.1:3000/portal/

然后浏览器访问:

arduino 复制代码
http://callback.example.com/portal/

而不是访问:

bash 复制代码
http://localhost:3000/portal/

这样第三方平台看到的顶层回调地址仍然是白名单地址,但实际请求会被代理到本地前端服务。

这个方案尤其适合跨项目联调:

  • 门户项目运行在本地端口 A。
  • 登录中转页项目运行在本地端口 B。
  • 浏览器访问统一的白名单域名。
  • 不同路径代理到不同本地服务。

登录后仍显示未登录态的排查

SSO 回跳成功并不代表页面一定能立刻展示登录态。

如果登录后页面右上角仍然显示未登录,可以重点检查:

  1. 当前访问域名是否一致。
  2. token 写入的 storage 是否和读取位置一致。
  3. cookie 的 domain / path 是否匹配。
  4. 业务接口是否真的带上了 token。
  5. 用户信息接口是否成功刷新。
  6. 是否一个页面在 localhost,另一个页面在白名单域名。

本地联调时,尤其要避免这样的情况:

bash 复制代码
登录中转页: http://callback.example.com/login/
门户首页:   http://localhost:3000/portal/

如果登录态依赖 cookie 或同域策略,两个域名不一致就可能导致页面读不到登录态。

更稳定的方式是统一使用白名单域名访问:

arduino 复制代码
http://callback.example.com/portal/

再通过代理转发到本地门户项目。


常见问题 Checklist

点击登录后出现 CORS

检查是否用 fetch 或 $request 请求第三方跳转地址。

SSO 登录应使用:

ini 复制代码
window.location.href = loginUrl

不要用 AJAX 承接第三方登录页。

回跳地址参数丢失

检查内层 redirect_uri 是否 encode。

错误示例:

bash 复制代码
callbackUrl?redirect_uri=http://localhost:3000/portal/?a=1&b=2

正确示例:

perl 复制代码
callbackUrl?redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fportal%2F%3Fa%3D1%26b%3D2

code 换 token 失败

检查 login 阶段传给后端的 redirectUrl 是否和 getRedirectUrl 阶段一致。

尤其注意:

  • 是否少了 redirect_uri
  • 是否编码层级不同。
  • 是否多了旧登录体系的参数。
  • 是否本地和白名单域名混用。

登录成功但页面还是未登录

检查当前页面访问域名是否和登录态写入域名一致。

如果第三方只允许白名单地址,本地也建议通过白名单域名访问页面,再代理到本地服务。

退出后再次点击登录被自动登录

检查是否只调用了业务系统 logout,没有调用第三方认证 logout。


最佳实践总结

这次接入后,可以沉淀出几条经验:

  1. SSO 跳转使用浏览器顶层跳转,不要用 AJAX 请求第三方登录页。
  2. redirect_uri 是否 encode,取决于它是否作为另一层 URL 的 query 参数。
  3. 不要机械地整体 encode,优先明确每一层 URL 的边界。
  4. 如果后端裸拼第三方认证地址,前后端必须明确编码职责。
  5. 发起登录和 code 换 token 时,redirectUrl 要保持一致。
  6. 登录中转页只做 code 换 token,不继续展示完整登录表单。
  7. 退出时要同时清理业务系统登录态和第三方认证态。
  8. 只有线上白名单地址时,本地联调可以通过代理把白名单域名转到本地服务。
  9. 跨项目联调时,尽量统一访问域名,避免 cookie / storage / token 域名不一致。
  10. 旧登录体系的 client_idscope 等参数要及时清理,避免污染新的 SSO 链路。

一句话结论

SSO 接入最关键的不是"跳过去再跳回来",而是把每一层 URL 的边界讲清楚:谁负责编码,谁负责解码,发起登录和换 token 时的 redirect_uri 是否完全一致,以及本地联调时浏览器实际访问的域名是否和登录态作用域一致。

相关推荐
朦胧之1 小时前
页面白屏卡住排查方法
前端·javascript
用户593608741401 小时前
Playwright 黑魔法:用 ClipboardEvent 绕过 React 富文本编辑器
前端
Ruihong1 小时前
Vue withDefaults 转 React:VuReact 怎么处理?
vue.js·react.js·面试
石山岭2 小时前
自己动手写了一个 Android 虚拟定位 App:GPSSimulate 技术实
android·前端
犇驫聊AI2 小时前
Chrome DevTools MCP + Claude Code 自定义skills生成接口代码生成器
前端·javascript
kyriewen2 小时前
别再这样写 async/await 了:我在 Code Review 中见过最多的 8 个错误
前端·javascript·面试
hoLzwEge2 小时前
node-linker VS shamefully-hoist
前端·前端框架
袋鱼不重2 小时前
解决 Web 端图片预览与下载颜色不一致的一种工程方案
前端·后端
风止何安啊3 小时前
教你用 JS + AI 实现简单的爬虫,零门槛爬取网页信息
前端