# SSO 接入指南 > 适用:任何需要接入本 SSO 体系的前端应用(Next.js / React / Vue / 其他均可参考)。 > 参考实现:[`external-app/`](../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(核心,敏感操作) **服务端**调用后端: ```http POST {BACKEND_API}/api/v1/basis/sso/access_token Content-Type: application/json { "client_id": "", "client_secret": "", "code": "", "grant_type": "authorization_code", "tenant": "" } ``` 成功响应(当前契约,不含用户信息): ```json { "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**:直接读 `token` cookie 判定。 - **客户端组件**:让服务端把 `authenticated: boolean` 标志传下来(**不要**在前端读 `httpOnly` cookie——读不到)。 - 避免依赖 `user` cookie 判定登录(后端不返回 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. 安全要求(必读) 1. **`client_secret` 绝不**出现在前端代码、URL、浏览器可读 cookie 中。 2. `access_token` / `refresh_token` 用 **`httpOnly` Cookie**,路径 `/`。 3. `redirect_uri` 必须与 SSO 注册**完全一致**(协议、域名、端口、路径都一致),否则换 token 失败。 4. 授权 `sso_code` **一次性、短时效**,由后端保证,前端不要重试。 5. 生产环境 Cookie 应设置 `Secure`(HTTPS)和合理的 `SameSite`(Lax/Strict)。 6. 敏感日志(如 `access_token` 响应)生产环境应脱敏或关闭。 --- ## 6. 参考实现:external-app(Next.js) 完整可运行示例位于 **sibling 仓库** [`../external-app/`](../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`(首页登录态判定): ```ts const token = cookieStore.get('token')?.value const authenticated = !!token return ``` `app/callback/page.tsx`(回调): ```ts 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): ```ts 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` 换后端读取(如 Express `req.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` | **你的应用服务端** | ③ 请求体: ```json { "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` → 写 `httpOnly` cookie → 完成。 照此即可接入。有问题对照 [`external-app/`](../external-app/) 实现,或查本仓库的 [`ADAPTATION_GUIDE.md`](./ADAPTATION_GUIDE.md)。