chore: initial commit of external-app SSO consumer
Next.js sample application that integrates with the mock SSO service: redirects to sso-mock for login, receives the one-time sso_code on its /callback route, and exchanges it for user info to establish a session. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
41
.gitignore
vendored
Normal file
41
.gitignore
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (never commit env; provide .env.example as a template instead)
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
172
app/DashboardPage.tsx
Normal file
172
app/DashboardPage.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import {
|
||||
AppShell,
|
||||
Group,
|
||||
Text,
|
||||
Button,
|
||||
Avatar,
|
||||
Paper,
|
||||
Stack,
|
||||
Badge,
|
||||
Code,
|
||||
Divider,
|
||||
SimpleGrid,
|
||||
Menu,
|
||||
} from '@mantine/core'
|
||||
import {
|
||||
IconLogout,
|
||||
IconUser,
|
||||
IconShield,
|
||||
IconServer,
|
||||
IconKey,
|
||||
} from '@tabler/icons-react'
|
||||
|
||||
interface UserInfo {
|
||||
id: string
|
||||
account: string
|
||||
name: string
|
||||
phone: string
|
||||
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])
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<Stack align="center" justify="center" h="100vh">
|
||||
<Text size="lg" c="dimmed">正在跳转到 SSO 登录页面...</Text>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
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}`
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell header={{ height: 60 }} padding="md">
|
||||
<AppShell.Header>
|
||||
<Group h="100%" px="md" justify="space-between">
|
||||
<Group gap="sm">
|
||||
<IconServer size={24} color="#0080FF" />
|
||||
<Text fw={700} size="lg">外部应用 Demo</Text>
|
||||
<Badge color="green" variant="light">已登录</Badge>
|
||||
</Group>
|
||||
<Menu shadow="md" width={220}>
|
||||
<Menu.Target>
|
||||
<Group gap="sm" style={{ cursor: 'pointer' }}>
|
||||
<Avatar color="brand" radius="xl" size="sm">
|
||||
{user.name?.[0] || 'U'}
|
||||
</Avatar>
|
||||
<Text size="sm" fw={500}>{user.name}</Text>
|
||||
</Group>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item leftSection={<IconUser size={14} />}>{user.account}</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item leftSection={<IconLogout size={14} />} color="red" onClick={handleLogout}>
|
||||
退出登录
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Group>
|
||||
</AppShell.Header>
|
||||
|
||||
<AppShell.Main>
|
||||
<Stack maw={800} mx="auto" pt="xl">
|
||||
<Text size="xl" fw={700}>登录成功!</Text>
|
||||
<Text c="dimmed">您已通过 Mock SSO 认证登录到此应用。</Text>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} mt="md">
|
||||
<Paper withBorder p="lg" radius="md">
|
||||
<Group gap="sm" mb="md">
|
||||
<IconUser size={20} color="#0080FF" />
|
||||
<Text fw={600}>用户信息</Text>
|
||||
</Group>
|
||||
<Stack gap="xs">
|
||||
<Group>
|
||||
<Text size="sm" c="dimmed" w={80}>ID</Text>
|
||||
<Code>{user.id}</Code>
|
||||
</Group>
|
||||
<Group>
|
||||
<Text size="sm" c="dimmed" w={80}>账号</Text>
|
||||
<Text size="sm">{user.account}</Text>
|
||||
</Group>
|
||||
<Group>
|
||||
<Text size="sm" c="dimmed" w={80}>姓名</Text>
|
||||
<Text size="sm">{user.name}</Text>
|
||||
</Group>
|
||||
<Group>
|
||||
<Text size="sm" c="dimmed" w={80}>手机</Text>
|
||||
<Text size="sm">{user.phone}</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder p="lg" radius="md">
|
||||
<Group gap="sm" mb="md">
|
||||
<IconShield size={20} color="#20C997" />
|
||||
<Text fw={600}>角色权限</Text>
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
{user.roles?.map((role: string) => (
|
||||
<Badge key={role} variant="light">{role}</Badge>
|
||||
))}
|
||||
</Group>
|
||||
</Paper>
|
||||
</SimpleGrid>
|
||||
|
||||
<Divider my="md" />
|
||||
|
||||
<Paper withBorder p="lg" radius="md">
|
||||
<Group gap="sm" mb="md">
|
||||
<IconKey size={20} color="#F59F00" />
|
||||
<Text fw={600}>SSO 流程说明</Text>
|
||||
</Group>
|
||||
<Stack gap="sm">
|
||||
<Text size="sm">
|
||||
<strong>1.</strong> 您打开此应用(<Code>http://localhost:4000</Code>)
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
<strong>2.</strong> 检测到未登录,自动跳转到 Mock SSO(<Code>http://localhost:5501/login</Code>)
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
<strong>3.</strong> 在 SSO 页面完成登录,SSO 返回 <Code>sso_code</Code>
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
<strong>4.</strong> 此应用用 <Code>sso_code</Code> 调用 <Code>POST /api/auth/verify</Code> 换取用户信息
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
<strong>5.</strong> 用户信息存入 Cookie,显示此页面
|
||||
</Text>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Group justify="center" mt="xl">
|
||||
<Button
|
||||
color="red"
|
||||
variant="light"
|
||||
leftSection={<IconLogout size={16} />}
|
||||
onClick={handleLogout}>
|
||||
退出登录(跳转回 SSO)
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</AppShell.Main>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
65
app/api/callback/route.ts
Normal file
65
app/api/callback/route.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
/**
|
||||
* SSO 回调接口
|
||||
* 接收 sso_code,调用 Mock SSO 验证接口换取用户信息,
|
||||
* 然后将用户信息写入 Cookie。
|
||||
*/
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const { code } = await req.json()
|
||||
|
||||
if (!code) {
|
||||
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 }),
|
||||
})
|
||||
|
||||
if (!verifyRes.ok) {
|
||||
const errData = await verifyRes.json().catch(() => ({}))
|
||||
return NextResponse.json(
|
||||
{ error: errData.error || 'SSO 验证失败' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const ssoData = await verifyRes.json()
|
||||
const { token, refresh_token, user, permissions } = ssoData
|
||||
|
||||
// 将用户信息和 token 写入 Cookie
|
||||
const response = NextResponse.json({ success: true })
|
||||
|
||||
response.cookies.set('user', JSON.stringify(user), {
|
||||
httpOnly: false, // 客户端需要读取用于展示
|
||||
path: '/',
|
||||
maxAge: 86400,
|
||||
})
|
||||
|
||||
response.cookies.set('token', token, {
|
||||
httpOnly: true,
|
||||
path: '/',
|
||||
maxAge: 86400,
|
||||
})
|
||||
|
||||
if (refresh_token) {
|
||||
response.cookies.set('refresh_token', refresh_token, {
|
||||
httpOnly: true,
|
||||
path: '/',
|
||||
maxAge: 86400 * 7,
|
||||
})
|
||||
}
|
||||
|
||||
return response
|
||||
} catch (err: any) {
|
||||
return NextResponse.json(
|
||||
{ error: err?.message || '回调处理失败' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
15
app/api/logout/route.ts
Normal file
15
app/api/logout/route.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
/**
|
||||
* 退出登录
|
||||
* 清除所有认证相关 Cookie。
|
||||
*/
|
||||
export async function POST() {
|
||||
const response = NextResponse.json({ success: true })
|
||||
|
||||
response.cookies.set('user', '', { path: '/', maxAge: 0 })
|
||||
response.cookies.set('token', '', { path: '/', maxAge: 0 })
|
||||
response.cookies.set('refresh_token', '', { path: '/', maxAge: 0 })
|
||||
|
||||
return response
|
||||
}
|
||||
74
app/callback/page.tsx
Normal file
74
app/callback/page.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
'use client'
|
||||
|
||||
import { Suspense } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { Stack, Text, Loader } from '@mantine/core'
|
||||
|
||||
function CallbackContent() {
|
||||
const searchParams = useSearchParams()
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const code = searchParams.get('sso_code')
|
||||
if (!code) {
|
||||
setError('缺少 sso_code 参数')
|
||||
return
|
||||
}
|
||||
|
||||
// 用 code 调用本应用的 /api/callback 换取用户信息
|
||||
fetch('/api/callback', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code }),
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) {
|
||||
const data = await res.json()
|
||||
throw new Error(data.error || '认证失败')
|
||||
}
|
||||
// 成功 -> 跳转到首页
|
||||
window.location.href = '/'
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err instanceof Error ? err.message : '认证失败')
|
||||
})
|
||||
}, [searchParams])
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Stack align="center" justify="center" h="100vh" gap="md">
|
||||
<Text size="lg" c="red">认证失败</Text>
|
||||
<Text size="sm" c="dimmed">{error}</Text>
|
||||
<Text
|
||||
size="sm"
|
||||
c="brand"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => (window.location.href = '/')}>
|
||||
返回首页重试
|
||||
</Text>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack align="center" justify="center" h="100vh" gap="md">
|
||||
<Loader size="lg" />
|
||||
<Text size="sm" c="dimmed">正在验证 SSO 登录...</Text>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
export default function CallbackPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<Stack align="center" justify="center" h="100vh" gap="md">
|
||||
<Loader size="lg" />
|
||||
<Text size="sm" c="dimmed">正在验证 SSO 登录...</Text>
|
||||
</Stack>
|
||||
}>
|
||||
<CallbackContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
8
app/globals.css
Normal file
8
app/globals.css
Normal file
@@ -0,0 +1,8 @@
|
||||
html, body {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
29
app/layout.tsx
Normal file
29
app/layout.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { ColorSchemeScript, MantineProvider, mantineHtmlProps } from '@mantine/core'
|
||||
import { Notifications } from '@mantine/notifications'
|
||||
import { theme } from './theme'
|
||||
import './globals.css'
|
||||
import '@mantine/core/styles.css'
|
||||
import '@mantine/core/styles.layer.css'
|
||||
import '@mantine/notifications/styles.css'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: '外部应用 Demo',
|
||||
description: 'SSO 外部应用测试项目',
|
||||
}
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="zh" {...mantineHtmlProps}>
|
||||
<head>
|
||||
<ColorSchemeScript defaultColorScheme="light" />
|
||||
</head>
|
||||
<body>
|
||||
<MantineProvider defaultColorScheme="light" theme={theme}>
|
||||
<Notifications />
|
||||
{children}
|
||||
</MantineProvider>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
18
app/page.tsx
Normal file
18
app/page.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { cookies } from 'next/headers'
|
||||
import DashboardPage from './DashboardPage'
|
||||
|
||||
export default async function Home() {
|
||||
const cookieStore = await cookies()
|
||||
const userCookie = cookieStore.get('user')?.value
|
||||
|
||||
let user = null
|
||||
if (userCookie) {
|
||||
try {
|
||||
user = JSON.parse(userCookie)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
return <DashboardPage user={user} />
|
||||
}
|
||||
15
app/theme.ts
Normal file
15
app/theme.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
'use client'
|
||||
|
||||
import { createTheme, MantineColorsTuple } from '@mantine/core'
|
||||
|
||||
const brandColors: MantineColorsTuple = [
|
||||
'#E6F4FF', '#D9EBFF', '#B6D8FF', '#8EC2FF', '#63AAFF',
|
||||
'#3393FF', '#0080FF', '#006EE6', '#005FC6', '#004897',
|
||||
]
|
||||
|
||||
export const theme = createTheme({
|
||||
primaryColor: 'brand',
|
||||
cursorType: 'pointer',
|
||||
colors: { brand: brandColors },
|
||||
defaultRadius: 'md',
|
||||
})
|
||||
7
next.config.ts
Normal file
7
next.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from 'next'
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: 'standalone',
|
||||
}
|
||||
|
||||
export default nextConfig
|
||||
27
package.json
Normal file
27
package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "external-app",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 4000",
|
||||
"build": "next build",
|
||||
"start": "next start"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mantine/core": "^8.3.6",
|
||||
"@mantine/hooks": "^8.3.6",
|
||||
"@mantine/notifications": "8.3.18",
|
||||
"@tabler/icons-react": "^3.35.0",
|
||||
"next": "16.0.1",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"postcss-preset-mantine": "^1.18.0",
|
||||
"postcss-simple-vars": "^7.0.1",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
1111
pnpm-lock.yaml
generated
Normal file
1111
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
16
postcss.config.mjs
Normal file
16
postcss.config.mjs
Normal file
@@ -0,0 +1,16 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
'postcss-preset-mantine': {},
|
||||
'postcss-simple-vars': {
|
||||
variables: {
|
||||
'mantine-breakpoint-xs': '36em',
|
||||
'mantine-breakpoint-sm': '48em',
|
||||
'mantine-breakpoint-md': '62em',
|
||||
'mantine-breakpoint-lg': '75em',
|
||||
'mantine-breakpoint-xl': '88em',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export default config
|
||||
41
tsconfig.json
Normal file
41
tsconfig.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user