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:
zhangheng
2026-07-06 18:11:23 +08:00
commit f737603b0c
14 changed files with 1639 additions and 0 deletions

172
app/DashboardPage.tsx Normal file
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,8 @@
html, body {
overflow-x: hidden;
}
* {
box-sizing: border-box;
padding: 0;
margin: 0;
}

29
app/layout.tsx Normal file
View 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
View 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
View 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',
})