diff --git a/.env.development b/.env.development
index 7f661e7..e413d1c 100644
--- a/.env.development
+++ b/.env.development
@@ -1,8 +1,17 @@
-# Mock SSO 服务地址(服务端用于 code 换取用户信息)
-SSO_URL="http://localhost:5501"
+# sso-portal 登录页地址(客户端跳转用,即 OAuth2 授权端点入口)
+NEXT_PUBLIC_SSO_URL="https://sso.softtest.cowarobot.com"
-# 本应用地址(作为 SSO 回调 app_url)
+# 本应用地址(作为 SSO 回调 client_redirect_uri)
NEXT_PUBLIC_APP_URL="http://localhost:4000"
-# 注册在 SSO 的应用 ID(app_id)
-SSO_APP_ID="app_demo_001"
+# OAuth2 client_id(同时作为 URL app_id 传给 sso-portal;非敏感,可下发前端)
+NEXT_PUBLIC_SSO_APP_ID="6wds5qua3b76hu748zrjmall"
+
+# OAuth2 client_secret(仅服务端 /api/callback 调 access_token 用,绝不下发前端)
+SSO_CLIENT_SECRET="oXwSxxrRUA0ijJ9N62qWI4oUrVJpLf5F"
+
+# 真实后端 SSO 宿主(服务端调 /api/v1/basis/sso/access_token)
+SSO_BACKEND_API="https://internal.softtest.cowarobot.com"
+
+# 租户标识
+SSO_TENANT="cowarobot"
diff --git a/.env.production b/.env.production
index 960f826..429a68f 100644
--- a/.env.production
+++ b/.env.production
@@ -1,8 +1,17 @@
-# Mock SSO 服务地址(集群内 svc 名,按实际部署填写)
-SSO_URL="http://sso-mock-svc:5501"
+# sso-portal 登录页地址(按实际部署填写,客户端跳转用)
+NEXT_PUBLIC_SSO_URL="http://sso-portal-svc:5501"
-# 本应用地址(作为 SSO 回调 app_url,按实际部署填写)
+# 本应用地址(作为 SSO 回调 client_redirect_uri,按实际部署填写)
NEXT_PUBLIC_APP_URL=""
-# 注册在 SSO 的应用 ID(app_id)
-SSO_APP_ID="app_demo_001"
+# OAuth2 client_id(同时作为 URL app_id 传给 sso-portal)
+NEXT_PUBLIC_SSO_APP_ID="6wds5qua3b76hu748zrjmall"
+
+# OAuth2 client_secret(⚠️ 含敏感信息,生产建议通过部署平台密钥注入,不入库)
+SSO_CLIENT_SECRET="oXwSxxrRUA0ijJ9N62qWI4oUrVJpLf5F"
+
+# 真实后端 SSO 宿主(集群内 svc 名,按实际部署填写)
+SSO_BACKEND_API="http://basis-app-svc:6610"
+
+# 租户标识
+SSO_TENANT="cowarobot"
diff --git a/.env.staging b/.env.staging
index 7f661e7..7997432 100644
--- a/.env.staging
+++ b/.env.staging
@@ -1,8 +1,17 @@
-# Mock SSO 服务地址(服务端用于 code 换取用户信息)
-SSO_URL="http://localhost:5501"
+# sso-portal 登录页地址(客户端跳转用,即 OAuth2 授权端点入口)
+NEXT_PUBLIC_SSO_URL="http://localhost:5501"
-# 本应用地址(作为 SSO 回调 app_url)
+# 本应用地址(作为 SSO 回调 client_redirect_uri)
NEXT_PUBLIC_APP_URL="http://localhost:4000"
-# 注册在 SSO 的应用 ID(app_id)
-SSO_APP_ID="app_demo_001"
+# OAuth2 client_id(同时作为 URL app_id 传给 sso-portal;非敏感,可下发前端)
+NEXT_PUBLIC_SSO_APP_ID="6wds5qua3b76hu748zrjmall"
+
+# OAuth2 client_secret(仅服务端 /api/callback 调 access_token 用,绝不下发前端)
+SSO_CLIENT_SECRET="oXwSxxrRUA0ijJ9N62qWI4oUrVJpLf5F"
+
+# 真实后端 SSO 宿主(服务端调 /api/v1/basis/sso/access_token)
+SSO_BACKEND_API="http://172.16.115.31:6610"
+
+# 租户标识
+SSO_TENANT="cowarobot"
diff --git a/README.md b/README.md
index d0cda92..7143652 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# External App(SSO 接入示例)
-基于 Next.js 的示例应用,演示如何接入 Mock SSO:跳转到 sso-mock 登录 → 在 `/callback` 收到一次性 `sso_code` → 用 code 换取用户信息并写入 Cookie 建立会话。
+基于 Next.js 的示例应用,演示如何接入真实 SSO(OAuth2 授权码流程):跳转到 sso-portal 登录 → 在 `/callback` 收到一次性 `sso_code` → 服务端用 code 向后端换 `access_token` 并写入 Cookie 建立会话。
## 快速开始
@@ -9,7 +9,9 @@ pnpm install
pnpm dev # http://localhost:4000
```
-> 需同时启动 [`sso-mock`](../sso-mock)(默认 `http://localhost:5501`)。
+> 需同时启动 [`sso-portal`](../sso-portal)(默认 `http://localhost:5501`)。
+>
+> 📘 **接入文档已移至 sso-portal 仓库**:[`sso-portal/INTEGRATION_GUIDE.md`](../sso-portal/INTEGRATION_GUIDE.md) —— 通用接入指南(前置准备 / OAuth2 流程 / 环境变量 / 安全 / FAQ / 后端接口速查)。本 README 仅为 external-app 项目自身的快速上手。
## 环境变量
@@ -18,22 +20,25 @@ pnpm dev # http://localhost:4000
| 变量 | 端 | 说明 | 示例 |
|------|----|------|------|
-| `SSO_URL` | 服务端 | Mock SSO 服务地址,`/api/callback` 用它调 `/api/auth/verify` 换用户信息 | dev: `http://localhost:5501`
prod: `http://sso-mock-svc:5501` |
-| `NEXT_PUBLIC_APP_URL` | 客户端 | 本应用地址,作为 SSO 回调 `app_url` 传给 sso-mock | `http://localhost:4000` |
-| `SSO_APP_ID` | 服务端 | 注册在 SSO 的应用 ID(`app_id`) | `app_demo_001` |
+| `NEXT_PUBLIC_SSO_URL` | 客户端 | sso-portal 登录页地址,跳转 `/login` 用 | dev: `http://localhost:5501`
prod: `http://sso-portal-svc:5501` |
+| `NEXT_PUBLIC_APP_URL` | 客户端 | 本应用地址,作为 SSO 回调 `client_redirect_uri` 传给 sso-portal | `http://localhost:4000` |
+| `NEXT_PUBLIC_SSO_APP_ID` | 两端 | OAuth2 `client_id`,同时作为 URL `app_id` 传给 sso-portal | `6wds5qua3b76hu748zrjmall` |
+| `SSO_CLIENT_SECRET` | 服务端 | OAuth2 `client_secret`,`/api/callback` 调 `access_token` 用,**绝不下发前端** | `oXwSxxrRUA0ijJ9N62qWI4oUrVJpLf5F` |
+| `SSO_BACKEND_API` | 服务端 | 真实后端 SSO 宿主,`/api/callback` 调 `/api/v1/basis/sso/access_token` 用 | dev: `http://172.16.115.31:6610`
prod: `http://basis-app-svc:6610` |
+| `SSO_TENANT` | 服务端 | 租户标识(SSO 接口的 tenant,与 sso-portal 的 `SSO_TENANT` 一致) | `cowarobot` |
-> 无密钥,可安全入库。`.env.production` 中的 `SSO_URL` / `NEXT_PUBLIC_APP_URL` 为占位值,请按实际部署填写。
+> ⚠️ `SSO_CLIENT_SECRET` 含敏感信息,生产建议通过部署平台密钥注入,不入库;本地私有覆盖写入 `.env*.local`(已被 `.gitignore` 忽略)。`.env.production` 中的 `NEXT_PUBLIC_SSO_URL` / `NEXT_PUBLIC_APP_URL` 为占位值,请按实际部署填写。
## SSO 接入流程
-1. 应用跳转到 sso-mock:`{SSO_URL}/login?app_id={SSO_APP_ID}&app_url={NEXT_PUBLIC_APP_URL}/callback`
-2. 用户在 sso-mock 登录(账号 / 手机 / 飞书)
-3. sso-mock 回跳:`{app_url}?sso_code=xxxxx`
-4. 本应用 `/callback` 页把 `sso_code` POST 给 `/api/callback` → 服务端调 sso-mock `/api/auth/verify` 换取 `{ user, token }` → 写入 Cookie
+1. 应用跳转到 sso-portal:`{NEXT_PUBLIC_SSO_URL}/login?app_id={NEXT_PUBLIC_SSO_APP_ID}&app_url={NEXT_PUBLIC_APP_URL}/callback`
+2. 用户在 sso-portal 登录(账号 / 手机)
+3. sso-portal 回跳:`{app_url}?sso_code=xxxxx`
+4. 本应用 `/callback` 页把 `sso_code` POST 给 `/api/callback` → 服务端用 `client_secret` 调后端 `/api/v1/basis/sso/access_token` 换 `access_token` → 写入 httpOnly Cookie
## 接口
| 接口 | 方法 | 说明 |
|------|------|------|
-| `/api/callback` | POST | 用 `sso_code` 换用户信息并写 Cookie |
+| `/api/callback` | POST | 用 `sso_code` 向后端换 `access_token` 并写 Cookie |
| `/api/logout` | POST | 清除会话 Cookie |
diff --git a/app/DashboardPage.tsx b/app/DashboardPage.tsx
index eb03177..911b16b 100644
--- a/app/DashboardPage.tsx
+++ b/app/DashboardPage.tsx
@@ -31,17 +31,41 @@ interface UserInfo {
roles: string[]
}
-export default function DashboardPage({ user }: { user: UserInfo | null }) {
- // 未登录 -> 跳转 SSO
- useEffect(() => {
- if (!user) {
- const appId = 'app_demo_001'
- const callbackUrl = encodeURIComponent('http://localhost:4000/callback')
- window.location.href = `http://localhost:5501/login?app_id=${appId}&app_url=${callbackUrl}`
- }
- }, [user])
+// 构建跳转 sso-portal 的登录 URL(OAuth2:app_id 即 client_id)
+const SSO_APP_ID = process.env.NEXT_PUBLIC_SSO_APP_ID || ''
+const SSO_URL = (process.env.NEXT_PUBLIC_SSO_URL || 'http://localhost:5501').replace(/\/+$/, '')
+const APP_URL = (process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:4000').replace(/\/+$/, '')
+const SSO_STATE_STORAGE_KEY = 'sso_login_state'
- if (!user) {
+function buildSsoLoginUrl(): string {
+ const bytes = new Uint8Array(32)
+ window.crypto.getRandomValues(bytes)
+ const state = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('')
+ const loginUrl = new URL(`${SSO_URL}/login`)
+
+ window.sessionStorage.setItem(SSO_STATE_STORAGE_KEY, state)
+ loginUrl.searchParams.set('app_id', SSO_APP_ID)
+ loginUrl.searchParams.set('app_url', `${APP_URL}/callback`)
+ loginUrl.searchParams.set('state', state)
+ return loginUrl.toString()
+}
+
+export default function DashboardPage({
+ user,
+ authenticated,
+}: {
+ user: UserInfo | null
+ // 登录态以 token 为准(拿到 access_token 即算成功);user 可能为 null
+ authenticated: boolean
+}) {
+ // 未登录(无 token)-> 跳转 SSO
+ useEffect(() => {
+ if (!authenticated) {
+ window.location.href = buildSsoLoginUrl()
+ }
+ }, [authenticated])
+
+ if (!authenticated) {
return (
正在跳转到 SSO 登录页面...
@@ -51,10 +75,8 @@ export default function DashboardPage({ user }: { user: UserInfo | null }) {
const handleLogout = async () => {
await fetch('/api/logout', { method: 'POST' })
- // 跳到 SSO 登录页(带上 app_id 让 SSO 知道是哪个应用)
- const appId = 'app_demo_001'
- const callbackUrl = encodeURIComponent('http://localhost:4000/callback')
- window.location.href = `http://localhost:5501/login?app_id=${appId}&app_url=${callbackUrl}`
+ // 跳到 SSO 登录页;每次登录都生成新的 state 防止登录 CSRF。
+ window.location.href = buildSsoLoginUrl()
}
return (
@@ -70,13 +92,15 @@ export default function DashboardPage({ user }: { user: UserInfo | null }) {
- {user.name?.[0] || 'U'}
+ {user?.name?.[0] || 'U'}
- {user.name}
+ {user?.name || '已登录用户'}
- }>{user.account}
+ }>
+ {user?.account || '(未获取到账号)'}
+
} color="red" onClick={handleLogout}>
退出登录
@@ -89,7 +113,9 @@ export default function DashboardPage({ user }: { user: UserInfo | null }) {
登录成功!
- 您已通过 Mock SSO 认证登录到此应用。
+
+ 您已通过 SSO 认证登录到此应用(OAuth2 授权码流程,已换取 access_token)。
+
@@ -97,24 +123,30 @@ export default function DashboardPage({ user }: { user: UserInfo | null }) {
用户信息
-
-
- ID
- {user.id}
-
-
- 账号
- {user.account}
-
-
- 姓名
- {user.name}
-
-
- 手机
- {user.phone}
-
-
+ {user ? (
+
+
+ ID
+ {user.id}
+
+
+ 账号
+ {user.account}
+
+
+ 姓名
+ {user.name}
+
+
+ 手机
+ {user.phone}
+
+
+ ) : (
+
+ 后端 access_token 接口未返回用户详情,暂无用户信息。
+
+ )}
@@ -122,11 +154,15 @@ export default function DashboardPage({ user }: { user: UserInfo | null }) {
角色权限
-
- {user.roles?.map((role: string) => (
- {role}
- ))}
-
+ {user?.roles?.length ? (
+
+ {user.roles.map((role: string) => (
+ {role}
+ ))}
+
+ ) : (
+ 暂无角色信息
+ )}
@@ -142,16 +178,16 @@ export default function DashboardPage({ user }: { user: UserInfo | null }) {
1. 您打开此应用(http://localhost:4000)
- 2. 检测到未登录,自动跳转到 Mock SSO(http://localhost:5501/login)
+ 2. 检测到未登录,自动跳转到 SSO(http://localhost:5501/login)
3. 在 SSO 页面完成登录,SSO 返回 sso_code
- 4. 此应用用 sso_code 调用 POST /api/auth/verify 换取用户信息
+ 4. 此应用用 sso_code 调用后端 /api/v1/basis/sso/access_token 换取 access_token
- 5. 用户信息存入 Cookie,显示此页面
+ 5. access_token 存入 httpOnly Cookie,即算登录成功;用户详情需后端 userinfo 接口提供
diff --git a/app/api/callback/route.ts b/app/api/callback/route.ts
index 8aa8a4e..ab9aa86 100644
--- a/app/api/callback/route.ts
+++ b/app/api/callback/route.ts
@@ -1,65 +1,138 @@
-import { NextResponse } from 'next/server'
+import { NextResponse } from "next/server";
/**
* SSO 回调接口
- * 接收 sso_code,调用 Mock SSO 验证接口换取用户信息,
- * 然后将用户信息写入 Cookie。
+ *
+ * OAuth2 授权码流程:用一次性授权 code 向真实后端换取 access_token,
+ * 然后将 token / 用户信息写入 Cookie 建立本地会话。
+ *
+ * ⚠️ client_secret 仅在服务端 env(SSO_CLIENT_SECRET),绝不下发浏览器。
*/
+const BACKEND = (
+ process.env.SSO_BACKEND_API || "http://172.16.115.31:6610"
+).replace(/\/+$/, "");
+
export async function POST(req: Request) {
try {
- const { code } = await req.json()
+ const { code } = await req.json();
if (!code) {
- return NextResponse.json({ error: '缺少 code 参数' }, { status: 400 })
+ return NextResponse.json({ error: "缺少 code 参数" }, { status: 400 });
}
- // 调用 Mock SSO 的验证接口
- const ssoUrl = process.env.SSO_URL || 'http://localhost:5501'
- const verifyRes = await fetch(`${ssoUrl}/api/auth/verify`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ code }),
- })
+ const clientId = process.env.NEXT_PUBLIC_SSO_APP_ID;
+ const clientSecret = process.env.SSO_CLIENT_SECRET;
+ const tenant = process.env.SSO_TENANT || "cowarobot";
- if (!verifyRes.ok) {
- const errData = await verifyRes.json().catch(() => ({}))
+ if (!clientId || !clientSecret) {
+ console.error(
+ "[external-app] 缺少 NEXT_PUBLIC_SSO_APP_ID 或 SSO_CLIENT_SECRET",
+ );
return NextResponse.json(
- { error: errData.error || 'SSO 验证失败' },
- { status: 401 }
- )
+ { error: "服务端 SSO 配置缺失" },
+ { status: 500 },
+ );
}
- const ssoData = await verifyRes.json()
- const { token, refresh_token, user, permissions } = ssoData
+ // 用授权 code 换 access_token(标准 OAuth2)
+ const tokenRes = await fetch(`${BACKEND}/api/v1/basis/sso/access_token`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ client_id: clientId,
+ client_secret: clientSecret,
+ code,
+ grant_type: "authorization_code",
+ tenant,
+ }),
+ });
- // 将用户信息和 token 写入 Cookie
- const response = NextResponse.json({ success: true })
+ const data = await tokenRes.json().catch(() => ({}));
+ console.log(
+ "[external-app] access_token 响应 status:",
+ tokenRes.status,
+ "| body:",
+ JSON.stringify(data).slice(0, 500),
+ );
- response.cookies.set('user', JSON.stringify(user), {
- httpOnly: false, // 客户端需要读取用于展示
- path: '/',
- maxAge: 86400,
- })
+ if (!tokenRes.ok) {
+ const message = data?.message || data?.msg || "换 token 失败";
+ console.error(
+ "[external-app] access_token error:",
+ tokenRes.status,
+ message,
+ );
+ return NextResponse.json({ error: message }, { status: 401 });
+ }
- response.cookies.set('token', token, {
+ // 兼容平铺 / 外壳两种响应;字段名兼容 user / user_info(basis 习惯用 user_info)
+ const token = data?.access_token ?? data?.data?.access_token;
+ const refreshToken = data?.refresh_token ?? data?.data?.refresh_token;
+
+ if (!token) {
+ console.error(
+ "[external-app] access_token 响应中无 token:",
+ JSON.stringify(data).slice(0, 300),
+ );
+ return NextResponse.json(
+ { error: "后端未返回 access_token" },
+ { status: 502 },
+ );
+ }
+
+ const infoRes = await fetch(
+ `${BACKEND}/api/v1/basis/sso/user_info?access_token=${token}`,
+ {
+ method: "GET",
+ },
+ );
+
+ const infoData = await infoRes.json().catch(() => ({}));
+
+ const user = infoData?.data;
+ console.log(
+ "[external-app] 提取 → token:",
+ !!token,
+ "| refresh_token:",
+ !!refreshToken,
+ "| user:",
+ !!user,
+ user
+ ? `(${Object.keys(user).join(",")})`
+ : "(无 — 登录态无法建立,首页会跳回 sso-portal)",
+ );
+
+ // 写入 Cookie 建立会话
+ const response = NextResponse.json({ success: true });
+
+ response.cookies.set("token", token, {
httpOnly: true,
- path: '/',
+ path: "/",
maxAge: 86400,
- })
+ });
- if (refresh_token) {
- response.cookies.set('refresh_token', refresh_token, {
+ if (refreshToken) {
+ response.cookies.set("refresh_token", refreshToken, {
httpOnly: true,
- path: '/',
+ path: "/",
maxAge: 86400 * 7,
- })
+ });
}
- return response
+ if (user) {
+ response.cookies.set("user", JSON.stringify(user), {
+ httpOnly: false, // 客户端需要读取用于展示
+ path: "/",
+ maxAge: 86400,
+ });
+ }
+
+ return response;
} catch (err: any) {
+ console.error("[external-app] callback error:", err?.message);
return NextResponse.json(
- { error: err?.message || '回调处理失败' },
- { status: 500 }
- )
+ { error: err?.message || "回调处理失败" },
+ { status: 500 },
+ );
}
}
diff --git a/app/callback/page.tsx b/app/callback/page.tsx
index 068ff19..7865b9b 100644
--- a/app/callback/page.tsx
+++ b/app/callback/page.tsx
@@ -1,20 +1,36 @@
'use client'
import { Suspense } from 'react'
-import { useEffect, useState } from 'react'
+import { useEffect, useRef, useState } from 'react'
import { useSearchParams } from 'next/navigation'
import { Stack, Text, Loader } from '@mantine/core'
+const SSO_STATE_STORAGE_KEY = 'sso_login_state'
+
function CallbackContent() {
const searchParams = useSearchParams()
const [error, setError] = useState(null)
+ const callbackStarted = useRef(false)
useEffect(() => {
+ if (callbackStarted.current) return
+
const code = searchParams.get('sso_code')
+ const state = searchParams.get('state')
+ const expectedState = window.sessionStorage.getItem(SSO_STATE_STORAGE_KEY)
+
if (!code) {
setError('缺少 sso_code 参数')
return
}
+ if (!state || !expectedState || state !== expectedState) {
+ window.sessionStorage.removeItem(SSO_STATE_STORAGE_KEY)
+ setError('state 无效或已过期,请重新登录')
+ return
+ }
+
+ callbackStarted.current = true
+ window.sessionStorage.removeItem(SSO_STATE_STORAGE_KEY)
// 用 code 调用本应用的 /api/callback 换取用户信息
fetch('/api/callback', {
diff --git a/app/page.tsx b/app/page.tsx
index 54ca5aa..98be090 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -3,6 +3,9 @@ import DashboardPage from './DashboardPage'
export default async function Home() {
const cookieStore = await cookies()
+ // 登录态以 token 为准:后端 access_token 接口只返回 token(不含用户信息),
+ // 拿到 access_token 即算登录成功;user 信息可选(后端暂未提供 userinfo)。
+ const token = cookieStore.get('token')?.value
const userCookie = cookieStore.get('user')?.value
let user = null
@@ -14,5 +17,5 @@ export default async function Home() {
}
}
- return
+ return
}