项目改名 sso-mock → sso-portal: - 重命名仓库目录 sso-mock/ → sso-portal/ - package.json name 改 sso-portal - app/layout.tsx title/description 改 SSO Portal - 源码 [sso-mock] console.log 前缀、注释全部 → [sso-portal] - 文档(README、ADAPTATION_GUIDE)、env 同步 - 生产 svc 名 sso-mock-svc → sso-portal-svc(external-app 引用同步) SSO tenant 改环境变量驱动: - 新增 SSO_TENANT env(默认 cowarobot) - login/route.ts 顶部 const SSO_TENANT,ssoBody 显式使用 - callSsoApi 改回纯透传(不再硬编码覆盖 tenant) - external-app 同步用 SSO_TENANT,命名对齐 external-app 登录态改为基于 token: - page.tsx 读 token cookie 判定 authenticated(不再依赖 user cookie) - DashboardPage 用 authenticated 决定跳转,user 为 null 时优雅兜底展示 - 解决「200 后又跳回 SSO」bug(access_token 接口不返回 user) sso-portal 缺 SSO 参数体验优化: - 缺 app_id/app_url 时 Alert 提示 + 禁用所有输入和登录按钮 - components/login/index.tsx 加 missingSsoParams 检测 - Alert 样式两排完整显示 部署脚本(参考 uirefbase): - 新增 build.sh(git → pnpm install → build → docker build → tag → push) - 新增 .gitlab-ci.yml(main 分支触发 build.sh prod) - 新增 Dockerfile(standalone 模式,端口 5501) - package.json 加 env-cmd 依赖 + build:stage/build 脚本 - next.config.ts 保持 output: 'standalone' 代码清理: - 删除 utils/code-store.ts - 删除 app/api/auth/verify/(连同目录) - 移除旧的 codeStore / generateCode 逻辑 文档: - ADAPTATION_GUIDE.md:完整重写对齐真实后端三个 SSO 接口(/sso/account、/sso/phone/smscode、/sso/access_token) - 新增 INTEGRATION_GUIDE.md(从 external-app 迁移过来作为通用接入指南) - README.md 同步更新(sso-portal 仓库门面 + 文档索引) - external-app/README.md 加交叉链接指向 sso-portal 的接入文档
11 KiB
SSO 接入指南
适用:任何需要接入本 SSO 体系的前端应用(Next.js / React / Vue / 其他均可参考)。 参考实现:
external-app/(Next.js 16,完整可运行示例)。 协议:标准 OAuth2 授权码流程。
1. 前置准备
向 SSO 管理员申请以下信息,全部必填:
| 字段 | 说明 | 示例 |
|---|---|---|
client_id |
OAuth2 应用标识(同时作为跳转 URL 的 app_id) |
6wds5qua3b76hu748zrjmall |
client_secret |
服务端密钥,⚠️ 仅服务端持有,绝不下发浏览器 | oXwSxxrRUA0ijJ9N62qWI4oUrVJpLf5F |
tenant |
租户标识 | cowarobot |
callback_url |
你的应用回调地址,需与 SSO 注册完全一致 | https://your-app.example.com/callback |
还需确认两个地址:
| 地址 | 用途 | 示例 |
|---|---|---|
SSO_URL |
SSO 登录页地址(用于跳转 /login) |
https://sso.softtest.cowarobot.com |
BACKEND_API |
后端 SSO 宿主(服务端调 access_token) |
http://basis-app-svc:6610 |
2. 整体流程(OAuth2 授权码)
[你的应用] [sso-portal] [后端 SSO]
│ │ │
│ ① 未登录,302 跳转 SSO │ │
├──────── /login?app_id=...&app_url=... ────────────▶│ │
│ │ │
│ │ ② 用户在 SSO 页登录 │
│ │ (账号/手机) │
│ │ │
│ ③ SSO 302 回跳,带一次性 sso_code │ │
│◀──────── /callback?sso_code=xxx ───────────────────┤ │
│ │ │
│ ④ 服务端用 sso_code + client_secret 换 token │ │
├──────────── POST /api/v1/basis/sso/access_token ───────────────────────────▶
│◀─────────── { access_token, refresh_token, ... } ──────────────────────────┤
│ │ │
│ ⑤ 写 httpOnly Cookie,重定向首页 │ │
│ → 已登录 ✓ │ │
关键点:
- 步骤 ④ 必须在你的服务端完成——
client_secret不能出现在前端代码/网络请求中。 sso_code一次性、短时效,由后端保证,无需额外处理(不要重试同一 code)。- 步骤 ⑤ 写完 cookie 后即可视为登录成功。
3. 步骤详解
3.1 构造 SSO 登录跳转 URL
未登录时,跳转到 SSO:
{SSO_URL}/login?app_id={client_id}&app_url={encodeURIComponent(callback_url)}
app_url必须encodeURIComponent。
3.2 接收回调
在 callback_url 对应的页面/路由接收 sso_code(URL query 参数)。
校验:
sso_code缺失 → 提示错误。- 存在 → 进入 3.3。
3.3 服务端换 token(核心,敏感操作)
服务端调用后端:
POST {BACKEND_API}/api/v1/basis/sso/access_token
Content-Type: application/json
{
"client_id": "<client_id>",
"client_secret": "<client_secret>",
"code": "<sso_code>",
"grant_type": "authorization_code",
"tenant": "<tenant>"
}
成功响应(当前契约,不含用户信息):
{
"code": 200,
"data": {
"access_token": "zfa1eIUz0cDUTHA3RVvjjWIL3SbvBu2g",
"expires_in": "7200",
"refresh_token": "Fpczl1EreLWw3BKhw6ejqa93SchN1rBF"
},
"msg": ""
}
关于用户信息:标准 OAuth2 的
access_token端点只返回 token,不返回 user。如需展示用户信息,需让后端补一个「用 access_token 换用户信息」的 userinfo 端点。当前external-app的用户信息卡片会显示「后端未返回用户详情」占位。
3.4 建立会话(写 Cookie)
| Cookie 名 | 内容 | 属性 |
|---|---|---|
token |
access_token |
httpOnly, path=/, maxAge=86400(或与 expires_in 对齐) |
refresh_token |
refresh_token(若有) |
httpOnly, path=/, maxAge=86400*7 |
user |
JSON.stringify(user)(若有) | 非 httpOnly(用于客户端展示) |
3.5 登录态判定
以 token cookie 为准(拿到 access_token 即登录成功)。
- 服务端组件 / SSR:直接读
tokencookie 判定。 - 客户端组件:让服务端把
authenticated: boolean标志传下来(不要在前端读httpOnlycookie——读不到)。 - 避免依赖
usercookie 判定登录(后端不返回 user 就会误判为未登录)。
3.6 登出
清空 token / refresh_token / user cookie(maxAge=0),然后跳转回 {SSO_URL}/login?app_id=...&app_url=...。
4. 关键配置项(环境变量)
下表是 Next.js 命名约定(NEXT_PUBLIC_ 前缀 = 客户端可见;无前缀 = 仅服务端)。其他框架按各自约定映射(例如 React + 独立后端,CLIENT_ID 可能由后端转发或注入 HTML)。
| 变量(Next.js) | 端 | 说明 |
|---|---|---|
NEXT_PUBLIC_SSO_URL |
客户端 | SSO 登录页地址,构造跳转 URL 用 |
NEXT_PUBLIC_APP_URL |
客户端 | 本应用地址,作为 client_redirect_uri |
NEXT_PUBLIC_SSO_APP_ID |
两端 | OAuth2 client_id,同时作为 URL app_id |
SSO_CLIENT_SECRET |
仅服务端 | client_secret,绝不下发前端 |
SSO_BACKEND_API |
服务端 | 后端 SSO 宿主(调 access_token) |
SSO_TENANT |
服务端 | 租户标识(如 cowarobot) |
⚠️
SSO_CLIENT_SECRET含敏感信息,生产建议通过部署平台密钥注入,不要入库。.env*.local已被.gitignore忽略,本地私有覆盖请写入.env*.local。
5. 安全要求(必读)
client_secret绝不出现在前端代码、URL、浏览器可读 cookie 中。access_token/refresh_token用httpOnlyCookie,路径/。redirect_uri必须与 SSO 注册完全一致(协议、域名、端口、路径都一致),否则换 token 失败。- 授权
sso_code一次性、短时效,由后端保证,前端不要重试。 - 生产环境 Cookie 应设置
Secure(HTTPS)和合理的SameSite(Lax/Strict)。 - 敏感日志(如
access_token响应)生产环境应脱敏或关闭。
6. 参考实现:external-app(Next.js)
完整可运行示例位于 sibling 仓库 ../external-app/:
external-app/
├── app/
│ ├── page.tsx # 首页:服务端读 token cookie → authenticated → DashboardPage
│ ├── DashboardPage.tsx # 登录后页面(client 组件,user 可为 null)
│ ├── callback/
│ │ └── page.tsx # 回调页:收 sso_code → POST /api/callback → 跳首页
│ └── api/
│ ├── callback/route.ts # 服务端:调 access_token → 写 httpOnly cookie
│ └── logout/route.ts # 清 cookie
├── .env.development
├── .env.staging
├── .env.production
└── README.md
关键代码片段(可直接对照阅读完整文件):
app/page.tsx(首页登录态判定):
const token = cookieStore.get('token')?.value
const authenticated = !!token
return <DashboardPage user={user} authenticated={authenticated} />
app/callback/page.tsx(回调):
const code = searchParams.get('sso_code')
await fetch('/api/callback', {
method: 'POST',
body: JSON.stringify({ code }),
})
window.location.href = '/' // 成功 → 首页
app/api/callback/route.ts(服务端换 token + 写 cookie):
const res = await fetch(`${BACKEND}/api/v1/basis/sso/access_token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
client_id, client_secret, code,
grant_type: 'authorization_code',
tenant: SSO_TENANT,
}),
})
const { access_token, refresh_token } = await res.json()
response.cookies.set('token', access_token, { httpOnly: true, path: '/', maxAge: 86400 })
response.cookies.set('refresh_token', refresh_token, { httpOnly: true, path: '/', maxAge: 86400*7 })
7. 其他框架适配要点
React(非 Next.js,需独立 Node 后端)
- 跳转/回调与 Next.js 同理,但
cookieStore换后端读取(如 Expressreq.cookies)。 - 客户端组件判定登录 → 通过后端 SSR 渲染时注入
authenticated,或客户端用/api/me(轻量探活)查。 - 跨域时 Cookie 需
SameSite=Lax/None; Secure。
Vue / 静态前端 + 独立后端
- 静态前端不能直接调
access_token(会暴露client_secret),必须经后端中转。 - 跳转/回调 HTML 端处理即可;后端提供换 token + 写 cookie 的接口。
跨域注意
- SSO 与你的应用若不同域:后端换 token 后写 Cookie 需正确
Domain/Path/SameSite。 sso-portal与应用不同源时,跳转 URL 的app_url必须为完整可公网访问的 URL。
8. 常见问题
| 现象 | 可能原因 / 排查 |
|---|---|
拿到 sso_code 后又跳回 SSO |
token cookie 未写入;服务端没读到 token;authenticated 未正确传给客户端 |
access_token 返回 401 |
client_secret 错;redirect_uri 与注册不一致;code 已用过 / 过期 |
redirect_uri 报错 |
与 SSO 注册的回调地址完全一致(含 http/https、端口、尾部斜杠) |
tenant 报错 |
SSO_TENANT 与后端约定不符(当前约定 cowarobot) |
| 用户信息不展示 | 标准行为——access_token 不返回 user。后端补 userinfo 接口即可 |
| 同一 code 报「已使用」 | 一次性,由后端保证;不要在客户端重试 |
code !== 200 误判 |
切到 OAuth2 流程后,code 字段含义变了(业务码 vs 授权码),不要再用 code === 200 判断成功 |
9. 后端接口参考(速查)
| # | 用途 | 接口(POST) | 调用方 |
|---|---|---|---|
| ① | 账号密码登录 → 授权 code | /api/v1/basis/sso/account |
sso-portal |
| ② | 手机验证码登录 → 授权 code | /api/v1/basis/sso/phone/smscode |
sso-portal |
| ③ | 授权 code → access_token | /api/v1/basis/sso/access_token |
你的应用服务端 |
③ 请求体:
{
"client_id": "...",
"client_secret": "...",
"code": "...",
"grant_type": "authorization_code",
"tenant": "cowarobot"
}
10. 一句话总结
跳转
{SSO_URL}/login?app_id=...&app_url=...→ 接sso_code→ 服务端用client_secret换access_token→ 写httpOnlycookie → 完成。
照此即可接入。有问题对照 external-app/ 实现,或查本仓库的 ADAPTATION_GUIDE.md。