Merge branch 'main' of http://113.45.67.59:3003/insiinc/nice
This commit is contained in:
commit
774ae000d6
|
@ -1,133 +0,0 @@
|
|||
# OIDC 架构更新总结
|
||||
|
||||
## 🎯 更新目标
|
||||
|
||||
将项目从混合认证架构改为标准的 OIDC 架构,确保所有用户认证都在 OIDC Provider 中处理。
|
||||
|
||||
## 🔄 主要改动
|
||||
|
||||
### 1. 删除客户端登录页面
|
||||
- ❌ 删除了 `apps/web/app/auth/login/page.tsx`
|
||||
- ❌ 删除了客户端应用中的自定义认证逻辑
|
||||
|
||||
### 2. 修复回调页面
|
||||
- ✅ 更新 `apps/web/app/auth/callback/page.tsx` 中的错误链接
|
||||
- ✅ 移除对已删除登录页面的引用
|
||||
|
||||
### 3. 添加测试页面
|
||||
- ✅ 创建 `apps/web/app/test-oidc/page.tsx` 用于测试OIDC流程
|
||||
- ✅ 在首页添加测试页面链接
|
||||
|
||||
### 4. 更新文档
|
||||
- ✅ 更新 `apps/backend/README.md` 以反映正确的架构
|
||||
|
||||
## 🏗️ 当前架构
|
||||
|
||||
### 正确的 OIDC 流程
|
||||
```
|
||||
用户点击登录
|
||||
↓
|
||||
客户端重定向到 OIDC Provider 授权端点
|
||||
↓
|
||||
OIDC Provider 显示内置登录页面
|
||||
↓
|
||||
用户在 Provider 页面上登录
|
||||
↓
|
||||
Provider 生成授权码并重定向回客户端
|
||||
↓
|
||||
客户端用授权码换取令牌
|
||||
↓
|
||||
认证完成
|
||||
```
|
||||
|
||||
### 架构优势
|
||||
|
||||
#### ✅ 已实现的正确做法
|
||||
- OIDC Provider 包含登录页面
|
||||
- 标准授权码流程
|
||||
- PKCE 支持
|
||||
- 内置会话管理
|
||||
- 自动令牌刷新
|
||||
|
||||
#### ❌ 已移除的错误做法
|
||||
- 客户端应用的登录页面
|
||||
- 自定义认证逻辑
|
||||
- 重复的用户管理
|
||||
- 混合认证流程
|
||||
|
||||
## 🧪 测试方法
|
||||
|
||||
### 1. 访问测试页面
|
||||
访问 `http://localhost:3001/test-oidc` 进行完整的流程测试
|
||||
|
||||
### 2. 测试 Discovery 端点
|
||||
在测试页面点击"测试 Discovery 端点"按钮
|
||||
|
||||
### 3. 完整认证流程测试
|
||||
1. 在测试页面点击"开始 OIDC 认证流程"
|
||||
2. 将跳转到 OIDC Provider 的内置登录页面
|
||||
3. 使用演示账号登录:`demouser` / `demo123`
|
||||
4. 登录成功后会重定向回客户端应用
|
||||
|
||||
## 🔧 技术细节
|
||||
|
||||
### OIDC Provider 配置
|
||||
```typescript
|
||||
export const oidcApp = createOIDCProvider({
|
||||
config: oidcConfig,
|
||||
useBuiltInAuth: true,
|
||||
builtInAuthConfig: {
|
||||
passwordValidator: validatePassword,
|
||||
sessionTTL: 24 * 60 * 60, // 24小时
|
||||
loginPageTitle: 'OIDC Demo 登录',
|
||||
brandName: 'OIDC Demo Provider',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### 客户端配置
|
||||
```typescript
|
||||
export const oidcConfig = {
|
||||
authority: 'http://localhost:3000/oidc',
|
||||
client_id: 'demo-client',
|
||||
redirect_uri: 'http://localhost:3001/auth/callback',
|
||||
response_type: 'code',
|
||||
scope: 'openid profile email',
|
||||
// ... 其他标准OIDC配置
|
||||
};
|
||||
```
|
||||
|
||||
## 📋 验证清单
|
||||
|
||||
- [x] 删除客户端登录页面
|
||||
- [x] 修复回调页面引用
|
||||
- [x] OIDC Provider 内置认证正常工作
|
||||
- [x] 标准 OIDC 流程可以完整运行
|
||||
- [x] Discovery 端点返回正确配置
|
||||
- [x] 文档已更新
|
||||
- [x] 测试页面可用
|
||||
|
||||
## 🚀 启动说明
|
||||
|
||||
1. 启动后端 OIDC Provider:
|
||||
```bash
|
||||
cd apps/backend
|
||||
bun run dev
|
||||
```
|
||||
|
||||
2. 启动前端客户端:
|
||||
```bash
|
||||
cd apps/web
|
||||
npm run dev
|
||||
```
|
||||
|
||||
3. 访问测试页面:
|
||||
http://localhost:3001/test-oidc
|
||||
|
||||
## 🎉 总结
|
||||
|
||||
现在项目已经完全符合标准的 OIDC 架构:
|
||||
- **分离关注点**: OIDC Provider 专注于认证,客户端专注于业务逻辑
|
||||
- **标准合规**: 完全符合 OpenID Connect 规范
|
||||
- **简化维护**: 认证逻辑集中在 Provider 中
|
||||
- **更好的安全性**: 用户凭据只在 Provider 中处理
|
|
@ -11,8 +11,6 @@ import minioClient from './minio';
|
|||
import { Client } from 'minio';
|
||||
|
||||
import { appRouter } from './trpc';
|
||||
|
||||
import { createBunWebSocket } from 'hono/bun';
|
||||
import { wsHandler, wsConfig } from './socket';
|
||||
|
||||
// 导入新的路由
|
||||
|
|
|
@ -1,134 +0,0 @@
|
|||
import { createOIDCProvider } from '@repo/oidc-provider';
|
||||
import { RedisStorageAdapter } from '@repo/oidc-provider';
|
||||
import type { OIDCClient, OIDCUser, OIDCProviderConfig } from '@repo/oidc-provider';
|
||||
import redis from './redis';
|
||||
|
||||
// 示例客户端数据
|
||||
const demoClients: OIDCClient[] = [
|
||||
{
|
||||
client_id: 'demo-client',
|
||||
client_secret: 'demo-client-secret',
|
||||
client_name: 'Demo Application',
|
||||
client_type: 'confidential',
|
||||
redirect_uris: [
|
||||
'http://localhost:3001/auth/callback',
|
||||
'http://localhost:8080/callback',
|
||||
'https://oauth.pstmn.io/v1/callback'
|
||||
],
|
||||
grant_types: ['authorization_code', 'refresh_token'],
|
||||
response_types: ['code'],
|
||||
scopes: ['openid', 'profile', 'email'],
|
||||
token_endpoint_auth_method: 'client_secret_basic',
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
},
|
||||
{
|
||||
client_id: 'demo-public-client',
|
||||
client_name: 'Demo Public Application',
|
||||
client_type: 'public',
|
||||
redirect_uris: [
|
||||
'http://localhost:3000/callback',
|
||||
'myapp://callback'
|
||||
],
|
||||
grant_types: ['authorization_code'],
|
||||
response_types: ['code'],
|
||||
scopes: ['openid', 'profile', 'email'],
|
||||
token_endpoint_auth_method: 'none',
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
}
|
||||
];
|
||||
|
||||
// 示例用户数据
|
||||
const demoUsers: OIDCUser[] = [
|
||||
{
|
||||
sub: 'demo-user',
|
||||
username: 'demouser',
|
||||
email: 'demo@example.com',
|
||||
email_verified: true,
|
||||
name: 'Demo User',
|
||||
given_name: 'Demo',
|
||||
family_name: 'User',
|
||||
picture: 'https://via.placeholder.com/150',
|
||||
profile: 'https://example.com/demouser',
|
||||
website: 'https://example.com',
|
||||
gender: 'prefer_not_to_say',
|
||||
birthdate: '1990-01-01',
|
||||
zoneinfo: 'Asia/Shanghai',
|
||||
locale: 'zh-CN',
|
||||
phone_number: '+86-123-4567-8901',
|
||||
phone_number_verified: true,
|
||||
address: {
|
||||
formatted: '北京市朝阳区建国门外大街1号',
|
||||
street_address: '建国门外大街1号',
|
||||
locality: '朝阳区',
|
||||
region: '北京市',
|
||||
postal_code: '100020',
|
||||
country: 'CN'
|
||||
},
|
||||
updated_at: Math.floor(Date.now() / 1000)
|
||||
}
|
||||
];
|
||||
|
||||
// 查找客户端的函数
|
||||
async function findClient(clientId: string): Promise<OIDCClient | null> {
|
||||
return demoClients.find(client => client.client_id === clientId) || null;
|
||||
}
|
||||
|
||||
// 查找用户的函数
|
||||
async function findUser(userId: string): Promise<OIDCUser | null> {
|
||||
return demoUsers.find(user => user.sub === userId) || null;
|
||||
}
|
||||
|
||||
// 密码验证函数
|
||||
async function validatePassword(username: string, password: string): Promise<string | null> {
|
||||
// 查找用户并验证密码
|
||||
const user = demoUsers.find(u => u.username === username);
|
||||
if (!user || password !== 'demo123') {
|
||||
return null;
|
||||
}
|
||||
return user.sub; // 返回用户ID
|
||||
}
|
||||
|
||||
// OIDC Provider 配置
|
||||
const oidcConfig: OIDCProviderConfig = {
|
||||
issuer: 'http://localhost:3000/oidc',
|
||||
signingKey: 'your-super-secret-signing-key-at-least-32-characters-long',
|
||||
signingAlgorithm: 'HS256',
|
||||
storage: new RedisStorageAdapter(redis),
|
||||
findClient,
|
||||
findUser,
|
||||
tokenTTL: {
|
||||
accessToken: 3600, // 1小时
|
||||
refreshToken: 30 * 24 * 3600, // 30天
|
||||
authorizationCode: 600, // 10分钟
|
||||
idToken: 3600, // 1小时
|
||||
},
|
||||
responseTypes: ['code'],
|
||||
grantTypes: ['authorization_code', 'refresh_token'],
|
||||
scopes: ['openid', 'profile', 'email', 'phone', 'address'],
|
||||
claims: [
|
||||
'sub', 'name', 'given_name', 'family_name', 'middle_name', 'nickname',
|
||||
'preferred_username', 'profile', 'picture', 'website', 'email',
|
||||
'email_verified', 'gender', 'birthdate', 'zoneinfo', 'locale',
|
||||
'phone_number', 'phone_number_verified', 'address', 'updated_at'
|
||||
],
|
||||
enablePKCE: true,
|
||||
requirePKCE: false,
|
||||
rotateRefreshTokens: true,
|
||||
};
|
||||
|
||||
// 使用新的内置认证处理器创建OIDC Provider
|
||||
export const oidcApp = createOIDCProvider({
|
||||
config: oidcConfig,
|
||||
useBuiltInAuth: true,
|
||||
builtInAuthConfig: {
|
||||
passwordValidator: validatePassword,
|
||||
sessionTTL: 24 * 60 * 60, // 24小时
|
||||
loginPageTitle: 'OIDC Demo 登录',
|
||||
brandName: 'OIDC Demo Provider',
|
||||
},
|
||||
});
|
||||
|
||||
// 导出示例数据用于测试
|
||||
export { demoClients, demoUsers, oidcConfig };
|
|
@ -0,0 +1,11 @@
|
|||
import { users } from './users';
|
||||
|
||||
// 密码验证函数
|
||||
export async function validatePassword(username: string, password: string): Promise<string | null> {
|
||||
// 查找用户并验证密码
|
||||
const user = users.demoUsers.find(u => u.username === username);
|
||||
if (!user || password !== 'demo123') {
|
||||
return null;
|
||||
}
|
||||
return user.sub; // 返回用户ID
|
||||
}
|
|
@ -0,0 +1,47 @@
|
|||
import type { OIDCClient } from '@repo/oidc-provider';
|
||||
|
||||
// 示例客户端数据
|
||||
const demoClients: OIDCClient[] = [
|
||||
{
|
||||
client_id: 'demo-client',
|
||||
client_secret: 'demo-client-secret',
|
||||
client_name: 'Demo Application',
|
||||
client_type: 'confidential',
|
||||
redirect_uris: [
|
||||
'http://localhost:3001/auth/callback',
|
||||
'http://localhost:8080/callback',
|
||||
'https://oauth.pstmn.io/v1/callback'
|
||||
],
|
||||
grant_types: ['authorization_code', 'refresh_token'],
|
||||
response_types: ['code'],
|
||||
scopes: ['openid', 'profile', 'email'],
|
||||
token_endpoint_auth_method: 'client_secret_basic',
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
},
|
||||
{
|
||||
client_id: 'demo-public-client',
|
||||
client_name: 'Demo Public Application',
|
||||
client_type: 'public',
|
||||
redirect_uris: [
|
||||
'http://localhost:3000/callback',
|
||||
'myapp://callback'
|
||||
],
|
||||
grant_types: ['authorization_code'],
|
||||
response_types: ['code'],
|
||||
scopes: ['openid', 'profile', 'email'],
|
||||
token_endpoint_auth_method: 'none',
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
}
|
||||
];
|
||||
|
||||
// 查找客户端的函数
|
||||
async function findClient(clientId: string): Promise<OIDCClient | null> {
|
||||
return demoClients.find(client => client.client_id === clientId) || null;
|
||||
}
|
||||
|
||||
export const clients = {
|
||||
findClient,
|
||||
demoClients,
|
||||
};
|
|
@ -0,0 +1,44 @@
|
|||
import { createOIDCProvider } from '@repo/oidc-provider';
|
||||
import { RedisStorageAdapter } from '@repo/oidc-provider';
|
||||
import type { OIDCProviderConfig } from '@repo/oidc-provider';
|
||||
import redis from '../redis';
|
||||
import { clients } from './clients';
|
||||
import { users } from './users';
|
||||
import { validatePassword } from './auth';
|
||||
|
||||
// OIDC Provider 配置
|
||||
const oidcConfig: OIDCProviderConfig = {
|
||||
issuer: 'http://localhost:3000/oidc',
|
||||
storage: new RedisStorageAdapter(redis),
|
||||
findClient: clients.findClient,
|
||||
findUser: users.findUser,
|
||||
authConfig: {
|
||||
passwordValidator: validatePassword,
|
||||
sessionTTL: 24 * 60 * 60, // 24小时
|
||||
pageConfig: {
|
||||
title: 'OIDC Provider 登录',
|
||||
brandName: 'Nice OIDC Provider',
|
||||
},
|
||||
},
|
||||
tokenTTL: {
|
||||
accessToken: 3600, // 1小时
|
||||
refreshToken: 30 * 24 * 3600, // 30天
|
||||
authorizationCode: 600, // 10分钟
|
||||
idToken: 3600, // 1小时
|
||||
},
|
||||
responseTypes: ['code'],
|
||||
grantTypes: ['authorization_code', 'refresh_token'],
|
||||
scopes: ['openid', 'profile', 'email', 'phone', 'address'],
|
||||
claims: [
|
||||
'sub', 'name', 'given_name', 'family_name', 'middle_name', 'nickname',
|
||||
'preferred_username', 'profile', 'picture', 'website', 'email',
|
||||
'email_verified', 'gender', 'birthdate', 'zoneinfo', 'locale',
|
||||
'phone_number', 'phone_number_verified', 'address', 'updated_at'
|
||||
],
|
||||
enablePKCE: true,
|
||||
requirePKCE: false,
|
||||
rotateRefreshTokens: true,
|
||||
};
|
||||
|
||||
// 创建OIDC Provider应用
|
||||
export const oidcApp = createOIDCProvider(oidcConfig);
|
|
@ -0,0 +1,42 @@
|
|||
import type { OIDCUser } from '@repo/oidc-provider';
|
||||
|
||||
// 示例用户数据
|
||||
const demoUsers: OIDCUser[] = [
|
||||
{
|
||||
sub: 'demo-user',
|
||||
username: 'demouser',
|
||||
email: 'demo@example.com',
|
||||
email_verified: true,
|
||||
name: 'Demo User',
|
||||
given_name: 'Demo',
|
||||
family_name: 'User',
|
||||
picture: 'https://via.placeholder.com/150',
|
||||
profile: 'https://example.com/demouser',
|
||||
website: 'https://example.com',
|
||||
gender: 'prefer_not_to_say',
|
||||
birthdate: '1990-01-01',
|
||||
zoneinfo: 'Asia/Shanghai',
|
||||
locale: 'zh-CN',
|
||||
phone_number: '+86-123-4567-8901',
|
||||
phone_number_verified: true,
|
||||
address: {
|
||||
formatted: '北京市朝阳区建国门外大街1号',
|
||||
street_address: '建国门外大街1号',
|
||||
locality: '朝阳区',
|
||||
region: '北京市',
|
||||
postal_code: '100020',
|
||||
country: 'CN'
|
||||
},
|
||||
updated_at: Math.floor(Date.now() / 1000)
|
||||
}
|
||||
];
|
||||
|
||||
// 查找用户的函数
|
||||
async function findUser(userId: string): Promise<OIDCUser | null> {
|
||||
return demoUsers.find(user => user.sub === userId) || null;
|
||||
}
|
||||
|
||||
export const users = {
|
||||
findUser,
|
||||
demoUsers,
|
||||
};
|
|
@ -1,120 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import { userManager } from '@/lib/oidc-config';
|
||||
import { Loader2, CheckCircle, XCircle } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@repo/ui/components/card';
|
||||
import { Alert, AlertDescription } from '@repo/ui/components/alert';
|
||||
|
||||
export default function CallbackPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleCallback = async () => {
|
||||
try {
|
||||
if (!userManager) {
|
||||
throw new Error('用户管理器未初始化');
|
||||
}
|
||||
|
||||
// 处理OIDC回调
|
||||
const user = await userManager.signinRedirectCallback();
|
||||
|
||||
if (user) {
|
||||
setStatus('success');
|
||||
// 延迟跳转到首页
|
||||
setTimeout(() => {
|
||||
router.push('/');
|
||||
}, 2000);
|
||||
} else {
|
||||
throw new Error('未收到用户信息');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('回调处理失败:', err);
|
||||
setError(err instanceof Error ? err.message : '未知错误');
|
||||
setStatus('error');
|
||||
}
|
||||
};
|
||||
|
||||
// 检查是否有授权码或错误参数
|
||||
const code = searchParams.get('code');
|
||||
const error = searchParams.get('error');
|
||||
const errorDescription = searchParams.get('error_description');
|
||||
|
||||
if (error) {
|
||||
setError(`${error}: ${errorDescription || '授权失败'}`);
|
||||
setStatus('error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (code) {
|
||||
handleCallback();
|
||||
} else {
|
||||
setError('缺少授权码');
|
||||
setStatus('error');
|
||||
}
|
||||
}, [searchParams, router]);
|
||||
|
||||
const getStatusIcon = () => {
|
||||
switch (status) {
|
||||
case 'loading':
|
||||
return <Loader2 className="h-8 w-8 animate-spin text-blue-500" />;
|
||||
case 'success':
|
||||
return <CheckCircle className="h-8 w-8 text-green-500" />;
|
||||
case 'error':
|
||||
return <XCircle className="h-8 w-8 text-red-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusMessage = () => {
|
||||
switch (status) {
|
||||
case 'loading':
|
||||
return '正在处理登录回调...';
|
||||
case 'success':
|
||||
return '登录成功!正在跳转...';
|
||||
case 'error':
|
||||
return '登录失败';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<div className="flex justify-center mb-4">{getStatusIcon()}</div>
|
||||
<CardTitle className="text-xl">{getStatusMessage()}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{status === 'error' && error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{status === 'loading' && (
|
||||
<div className="text-center text-sm text-gray-600 dark:text-gray-400">
|
||||
<p>请等待,正在验证您的登录信息...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'success' && (
|
||||
<div className="text-center text-sm text-gray-600 dark:text-gray-400">
|
||||
<p>登录成功!即将跳转到首页...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'error' && (
|
||||
<div className="text-center mt-4">
|
||||
<button onClick={() => router.push('/')} className="text-blue-600 hover:text-blue-800 text-sm">
|
||||
返回首页
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,172 +1,5 @@
|
|||
'use client';
|
||||
|
||||
import { useAuth } from '@/providers/auth-provider';
|
||||
import { UserProfile } from '@/components/user-profile';
|
||||
import { LoginButton } from '@/components/login-button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@repo/ui/components/card';
|
||||
import { Badge } from '@repo/ui/components/badge';
|
||||
import { Separator } from '@repo/ui/components/separator';
|
||||
import { Shield, Key, Users, CheckCircle, Info } from 'lucide-react';
|
||||
|
||||
export default function HomePage() {
|
||||
const { isAuthenticated, isLoading, error } = useAuth();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 dark:from-gray-900 dark:to-gray-800">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
{/* 页面标题 */}
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-4xl font-bold text-gray-900 dark:text-white mb-4">OIDC 认证演示</h1>
|
||||
<p className="text-xl text-gray-600 dark:text-gray-300 max-w-2xl mx-auto">
|
||||
基于 OpenID Connect 协议的安全认证系统演示
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<a
|
||||
href="/test-oidc"
|
||||
className="inline-flex items-center px-4 py-2 text-sm font-medium text-blue-600 hover:text-blue-800 border border-blue-300 rounded-lg hover:bg-blue-50 dark:text-blue-400 dark:border-blue-600 dark:hover:bg-blue-900"
|
||||
>
|
||||
测试 OIDC 流程
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 功能特性卡片 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Shield className="h-8 w-8 text-blue-600 mb-2" />
|
||||
<CardTitle className="text-lg">安全认证</CardTitle>
|
||||
<CardDescription>基于 OAuth 2.0 和 OpenID Connect 标准的安全认证流程</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Key className="h-8 w-8 text-green-600 mb-2" />
|
||||
<CardTitle className="text-lg">Token 管理</CardTitle>
|
||||
<CardDescription>自动管理访问令牌、刷新令牌和 ID 令牌的生命周期</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Users className="h-8 w-8 text-purple-600 mb-2" />
|
||||
<CardTitle className="text-lg">用户信息</CardTitle>
|
||||
<CardDescription>获取和展示完整的用户配置文件信息</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 状态显示 */}
|
||||
{error && (
|
||||
<Card className="mb-6 border-red-200 bg-red-50 dark:border-red-800 dark:bg-red-900/20">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 text-red-700 dark:text-red-300">
|
||||
<Info className="h-5 w-5" />
|
||||
<span className="font-medium">错误:</span>
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 主要内容区域 */}
|
||||
<div className="flex flex-col items-center space-y-8">
|
||||
{isLoading ? (
|
||||
<Card className="w-full max-w-2xl">
|
||||
<CardContent className="p-8">
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-4 bg-gray-200 rounded w-1/3 mx-auto"></div>
|
||||
<div className="h-4 bg-gray-200 rounded w-1/2 mx-auto"></div>
|
||||
<div className="h-4 bg-gray-200 rounded w-2/3 mx-auto"></div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : isAuthenticated ? (
|
||||
<UserProfile />
|
||||
) : (
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-2xl">欢迎使用</CardTitle>
|
||||
<CardDescription>请点击下方按钮开始 OIDC 认证流程</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col items-center space-y-4">
|
||||
<LoginButton size="lg" className="w-full" />
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 space-y-2">
|
||||
<p className="font-medium">演示账户信息:</p>
|
||||
<div className="bg-gray-100 dark:bg-gray-800 p-3 rounded-md">
|
||||
<p>
|
||||
用户名: <code className="text-sm">demouser</code>
|
||||
</p>
|
||||
<p>
|
||||
密码: <code className="text-sm">demo123</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 技术信息 */}
|
||||
<Card className="w-full max-w-4xl">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<CheckCircle className="h-5 w-5 text-green-600" />
|
||||
技术实现特性
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h4 className="font-semibold mb-3">前端技术栈</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="outline">Next.js 15</Badge>
|
||||
<Badge variant="outline">React 19</Badge>
|
||||
<Badge variant="outline">TypeScript</Badge>
|
||||
<Badge variant="outline">oidc-client-ts</Badge>
|
||||
<Badge variant="outline">Tailwind CSS</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-semibold mb-3">后端技术栈</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="outline">Hono</Badge>
|
||||
<Badge variant="outline">OIDC Provider</Badge>
|
||||
<Badge variant="outline">Redis</Badge>
|
||||
<Badge variant="outline">JWT</Badge>
|
||||
<Badge variant="outline">PKCE</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-semibold mb-3">安全特性</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="secondary">授权码流程</Badge>
|
||||
<Badge variant="secondary">PKCE 支持</Badge>
|
||||
<Badge variant="secondary">Token 轮换</Badge>
|
||||
<Badge variant="secondary">安全存储</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-semibold mb-3">支持的作用域</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="default">openid</Badge>
|
||||
<Badge variant="default">profile</Badge>
|
||||
<Badge variant="default">email</Badge>
|
||||
<Badge variant="default">phone</Badge>
|
||||
<Badge variant="default">address</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <></>;
|
||||
}
|
||||
|
|
|
@ -1,248 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@repo/ui/components/card';
|
||||
import { Button } from '@repo/ui/components/button';
|
||||
import { Badge } from '@repo/ui/components/badge';
|
||||
import { Alert, AlertDescription } from '@repo/ui/components/alert';
|
||||
import { CheckCircle, XCircle, Loader2, ArrowRight, Key, User, Shield } from 'lucide-react';
|
||||
|
||||
export default function TestOidcPage() {
|
||||
const [testResults, setTestResults] = useState<{
|
||||
discovery: 'idle' | 'loading' | 'success' | 'error';
|
||||
discoveryData?: any;
|
||||
discoveryError?: string;
|
||||
}>({
|
||||
discovery: 'idle',
|
||||
});
|
||||
|
||||
const testDiscoveryEndpoint = async () => {
|
||||
setTestResults((prev) => ({ ...prev, discovery: 'loading' }));
|
||||
|
||||
try {
|
||||
const response = await fetch('http://localhost:3000/oidc/.well-known/openid_configuration');
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setTestResults((prev) => ({
|
||||
...prev,
|
||||
discovery: 'success',
|
||||
discoveryData: data,
|
||||
}));
|
||||
} catch (error) {
|
||||
setTestResults((prev) => ({
|
||||
...prev,
|
||||
discovery: 'error',
|
||||
discoveryError: error instanceof Error ? error.message : '未知错误',
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const startOidcFlow = () => {
|
||||
const params = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: 'demo-client',
|
||||
redirect_uri: 'http://localhost:3001/auth/callback',
|
||||
scope: 'openid profile email',
|
||||
state: `test-${Date.now()}`,
|
||||
});
|
||||
|
||||
window.location.href = `http://localhost:3000/oidc/auth?${params.toString()}`;
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'loading':
|
||||
return <Loader2 className="h-5 w-5 animate-spin text-blue-500" />;
|
||||
case 'success':
|
||||
return <CheckCircle className="h-5 w-5 text-green-500" />;
|
||||
case 'error':
|
||||
return <XCircle className="h-5 w-5 text-red-500" />;
|
||||
default:
|
||||
return <div className="h-5 w-5 rounded-full border-2 border-gray-300" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 dark:from-gray-900 dark:to-gray-800 p-8">
|
||||
<div className="max-w-4xl mx-auto space-y-8">
|
||||
{/* 页面标题 */}
|
||||
<div className="text-center">
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-4">OIDC 流程测试</h1>
|
||||
<p className="text-lg text-gray-600 dark:text-gray-300">测试和验证 OpenID Connect 认证流程的各个环节</p>
|
||||
</div>
|
||||
|
||||
{/* OIDC 流程步骤 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5" />
|
||||
标准 OIDC 认证流程
|
||||
</CardTitle>
|
||||
<CardDescription>按照正确的 OIDC 架构,所有登录都在 OIDC Provider 中处理</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* 流程步骤图示 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-4 text-center">
|
||||
<div className="flex flex-col items-center space-y-2">
|
||||
<div className="w-12 h-12 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center">
|
||||
<User className="h-6 w-6 text-blue-600 dark:text-blue-300" />
|
||||
</div>
|
||||
<p className="text-sm font-medium">用户点击登录</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center">
|
||||
<ArrowRight className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center space-y-2">
|
||||
<div className="w-12 h-12 bg-green-100 dark:bg-green-900 rounded-full flex items-center justify-center">
|
||||
<Shield className="h-6 w-6 text-green-600 dark:text-green-300" />
|
||||
</div>
|
||||
<p className="text-sm font-medium">重定向到 Provider</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center">
|
||||
<ArrowRight className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center space-y-2">
|
||||
<div className="w-12 h-12 bg-purple-100 dark:bg-purple-900 rounded-full flex items-center justify-center">
|
||||
<Key className="h-6 w-6 text-purple-600 dark:text-purple-300" />
|
||||
</div>
|
||||
<p className="text-sm font-medium">返回授权码</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 测试按钮 */}
|
||||
<div className="flex justify-center">
|
||||
<Button onClick={startOidcFlow} size="lg" className="px-8">
|
||||
开始 OIDC 认证流程
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 提示信息 */}
|
||||
<Alert>
|
||||
<Shield className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
点击上方按钮将重定向到 OIDC Provider 的登录页面。
|
||||
<br />
|
||||
<strong>演示账号:</strong> demouser / demo123
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Discovery 端点测试 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
{getStatusIcon(testResults.discovery)}
|
||||
Discovery 端点测试
|
||||
</CardTitle>
|
||||
<CardDescription>测试 OIDC Provider 的配置发现端点</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Button onClick={testDiscoveryEndpoint} disabled={testResults.discovery === 'loading'}>
|
||||
{testResults.discovery === 'loading' ? '测试中...' : '测试 Discovery 端点'}
|
||||
</Button>
|
||||
|
||||
{testResults.discovery === 'error' && (
|
||||
<Alert variant="destructive">
|
||||
<XCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<strong>错误:</strong> {testResults.discoveryError}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{testResults.discovery === 'success' && testResults.discoveryData && (
|
||||
<div className="space-y-4">
|
||||
<Alert>
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<strong>成功!</strong> OIDC Provider 配置已获取
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="bg-gray-50 dark:bg-gray-800 p-4 rounded-lg">
|
||||
<h4 className="font-semibold mb-3">Provider 信息</h4>
|
||||
<div className="space-y-2 text-sm">
|
||||
<p>
|
||||
<strong>Issuer:</strong>{' '}
|
||||
<code className="bg-gray-200 dark:bg-gray-700 px-1 rounded">
|
||||
{testResults.discoveryData.issuer}
|
||||
</code>
|
||||
</p>
|
||||
<p>
|
||||
<strong>授权端点:</strong>{' '}
|
||||
<code className="bg-gray-200 dark:bg-gray-700 px-1 rounded">
|
||||
{testResults.discoveryData.authorization_endpoint}
|
||||
</code>
|
||||
</p>
|
||||
<p>
|
||||
<strong>令牌端点:</strong>{' '}
|
||||
<code className="bg-gray-200 dark:bg-gray-700 px-1 rounded">
|
||||
{testResults.discoveryData.token_endpoint}
|
||||
</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-semibold mb-3">支持的功能</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{testResults.discoveryData.response_types_supported?.map((type: string) => (
|
||||
<Badge key={type} variant="outline">
|
||||
{type}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{testResults.discoveryData.scopes_supported?.map((scope: string) => (
|
||||
<Badge key={scope} variant="secondary">
|
||||
{scope}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 架构说明 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>正确的 OIDC 架构</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h4 className="font-semibold text-green-600 mb-2">✅ 已实现</h4>
|
||||
<ul className="space-y-1 text-sm">
|
||||
<li>• OIDC Provider 包含登录页面</li>
|
||||
<li>• 标准授权码流程</li>
|
||||
<li>• PKCE 支持</li>
|
||||
<li>• 内置会话管理</li>
|
||||
<li>• 自动令牌刷新</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-red-600 mb-2">❌ 已移除</h4>
|
||||
<ul className="space-y-1 text-sm">
|
||||
<li>• 客户端应用的登录页面</li>
|
||||
<li>• 自定义认证逻辑</li>
|
||||
<li>• 重复的用户管理</li>
|
||||
<li>• 混合认证流程</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,31 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import { useAuth } from '@/providers/auth-provider';
|
||||
import { Button } from '@repo/ui/components/button';
|
||||
import { LogIn, Loader2 } from 'lucide-react';
|
||||
|
||||
interface LoginButtonProps {
|
||||
className?: string;
|
||||
variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link';
|
||||
size?: 'default' | 'sm' | 'lg' | 'icon';
|
||||
}
|
||||
|
||||
export function LoginButton({ className, variant = 'default', size = 'default' }: LoginButtonProps) {
|
||||
const { login, isLoading } = useAuth();
|
||||
|
||||
return (
|
||||
<Button onClick={login} disabled={isLoading} variant={variant} size={size} className={className}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
连接中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LogIn className="mr-2 h-4 w-4" />
|
||||
登录
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
|
@ -3,8 +3,6 @@
|
|||
import * as React from 'react';
|
||||
import { ThemeProvider as NextThemesProvider } from 'next-themes';
|
||||
import QueryProvider from '@/providers/query-provider';
|
||||
import { AuthProvider } from '@/providers/auth-provider';
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<NextThemesProvider
|
||||
|
@ -15,7 +13,7 @@ export function Providers({ children }: { children: React.ReactNode }) {
|
|||
enableColorScheme
|
||||
>
|
||||
<QueryProvider>
|
||||
<AuthProvider>{children}</AuthProvider>
|
||||
{children}
|
||||
</QueryProvider>
|
||||
</NextThemesProvider>
|
||||
);
|
||||
|
|
|
@ -1,251 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import { useAuth } from '@/providers/auth-provider';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@repo/ui/components/card';
|
||||
import { Button } from '@repo/ui/components/button';
|
||||
import { Badge } from '@repo/ui/components/badge';
|
||||
import { Separator } from '@repo/ui/components/separator';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@repo/ui/components/avatar';
|
||||
import { LogOut, User, Mail, Phone, MapPin, Calendar, Globe } from 'lucide-react';
|
||||
|
||||
export function UserProfile() {
|
||||
const { user, isAuthenticated, logout, isLoading } = useAuth();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card className="w-full max-w-2xl">
|
||||
<CardContent className="p-6">
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-4 bg-gray-200 rounded w-1/3"></div>
|
||||
<div className="h-4 bg-gray-200 rounded w-1/2"></div>
|
||||
<div className="h-4 bg-gray-200 rounded w-2/3"></div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated || !user) {
|
||||
return (
|
||||
<Card className="w-full max-w-2xl">
|
||||
<CardHeader>
|
||||
<CardTitle>未登录</CardTitle>
|
||||
<CardDescription>请先登录以查看用户信息</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const profile = user.profile;
|
||||
const formatDate = (timestamp?: number) => {
|
||||
if (!timestamp) return '未知';
|
||||
return new Date(timestamp * 1000).toLocaleString('zh-CN');
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-2xl">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Avatar className="h-12 w-12">
|
||||
<AvatarImage src={profile.picture} alt={profile.name} />
|
||||
<AvatarFallback>{profile.name?.charAt(0) || profile.preferred_username?.charAt(0) || 'U'}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<CardTitle className="text-xl">{profile.name || profile.preferred_username || '未知用户'}</CardTitle>
|
||||
<CardDescription>用户ID: {profile.sub}</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" onClick={logout} className="gap-2">
|
||||
<LogOut className="h-4 w-4" />
|
||||
登出
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
{/* 基本信息 */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3 flex items-center gap-2">
|
||||
<User className="h-5 w-5" />
|
||||
基本信息
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{profile.given_name && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-600 dark:text-gray-400">名</label>
|
||||
<p className="text-sm">{profile.given_name}</p>
|
||||
</div>
|
||||
)}
|
||||
{profile.family_name && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-600 dark:text-gray-400">姓</label>
|
||||
<p className="text-sm">{profile.family_name}</p>
|
||||
</div>
|
||||
)}
|
||||
{profile.nickname && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-600 dark:text-gray-400">昵称</label>
|
||||
<p className="text-sm">{profile.nickname}</p>
|
||||
</div>
|
||||
)}
|
||||
{profile.gender && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-600 dark:text-gray-400">性别</label>
|
||||
<p className="text-sm">{profile.gender}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 联系信息 */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3 flex items-center gap-2">
|
||||
<Mail className="h-5 w-5" />
|
||||
联系信息
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{profile.email && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-600 dark:text-gray-400">邮箱</label>
|
||||
<p className="text-sm">{profile.email}</p>
|
||||
</div>
|
||||
<Badge variant={profile.email_verified ? 'default' : 'secondary'}>
|
||||
{profile.email_verified ? '已验证' : '未验证'}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
{profile.phone_number && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-600 dark:text-gray-400">电话</label>
|
||||
<p className="text-sm">{profile.phone_number}</p>
|
||||
</div>
|
||||
<Badge variant={profile.phone_number_verified ? 'default' : 'secondary'}>
|
||||
{profile.phone_number_verified ? '已验证' : '未验证'}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{profile.address && (
|
||||
<>
|
||||
<Separator />
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3 flex items-center gap-2">
|
||||
<MapPin className="h-5 w-5" />
|
||||
地址信息
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{profile.address.formatted && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-600 dark:text-gray-400">完整地址</label>
|
||||
<p className="text-sm">{profile.address.formatted}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{profile.address.country && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-600 dark:text-gray-400">国家</label>
|
||||
<p className="text-sm">{profile.address.country}</p>
|
||||
</div>
|
||||
)}
|
||||
{profile.address.region && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-600 dark:text-gray-400">省/州</label>
|
||||
<p className="text-sm">{profile.address.region}</p>
|
||||
</div>
|
||||
)}
|
||||
{profile.address.locality && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-600 dark:text-gray-400">城市</label>
|
||||
<p className="text-sm">{profile.address.locality}</p>
|
||||
</div>
|
||||
)}
|
||||
{profile.address.postal_code && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-600 dark:text-gray-400">邮编</label>
|
||||
<p className="text-sm">{profile.address.postal_code}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 其他信息 */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3 flex items-center gap-2">
|
||||
<Globe className="h-5 w-5" />
|
||||
其他信息
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{profile.birthdate && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-600 dark:text-gray-400">生日</label>
|
||||
<p className="text-sm">{profile.birthdate}</p>
|
||||
</div>
|
||||
)}
|
||||
{profile.zoneinfo && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-600 dark:text-gray-400">时区</label>
|
||||
<p className="text-sm">{profile.zoneinfo}</p>
|
||||
</div>
|
||||
)}
|
||||
{profile.locale && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-600 dark:text-gray-400">语言</label>
|
||||
<p className="text-sm">{profile.locale}</p>
|
||||
</div>
|
||||
)}
|
||||
{profile.updated_at && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-600 dark:text-gray-400">更新时间</label>
|
||||
<p className="text-sm">{formatDate(profile.updated_at)}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Token 信息 */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3 flex items-center gap-2">
|
||||
<Calendar className="h-5 w-5" />
|
||||
Token 信息
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{user.expires_at && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-600 dark:text-gray-400">访问令牌过期时间</label>
|
||||
<p className="text-sm">{new Date(user.expires_at * 1000).toLocaleString('zh-CN')}</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-600 dark:text-gray-400">Token类型</label>
|
||||
<p className="text-sm">{user.token_type}</p>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="text-sm font-medium text-gray-600 dark:text-gray-400">作用域</label>
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{user.scope?.split(' ').map((scope) => (
|
||||
<Badge key={scope} variant="outline" className="text-xs">
|
||||
{scope}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
|
@ -1,35 +0,0 @@
|
|||
import { UserManager, WebStorageStateStore } from 'oidc-client-ts';
|
||||
|
||||
// 创建存储配置的函数,避免 SSR 问题
|
||||
const createUserStore = () => {
|
||||
if (typeof window !== 'undefined' && window.localStorage) {
|
||||
return new WebStorageStateStore({ store: window.localStorage });
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// OIDC 客户端配置
|
||||
export const oidcConfig = {
|
||||
authority: 'http://localhost:3000/oidc', // 后端OIDC provider地址
|
||||
client_id: 'demo-client',
|
||||
client_secret: 'demo-client-secret',
|
||||
redirect_uri: 'http://localhost:3001/auth/callback',
|
||||
post_logout_redirect_uri: 'http://localhost:3001',
|
||||
response_type: 'code',
|
||||
scope: 'openid profile email',
|
||||
automaticSilentRenew: true,
|
||||
includeIdTokenInSilentRenew: true,
|
||||
revokeTokensOnSignout: true,
|
||||
...(typeof window !== 'undefined' && { userStore: createUserStore() }),
|
||||
};
|
||||
|
||||
// 创建用户管理器实例
|
||||
export const userManager = typeof window !== 'undefined' ? new UserManager(oidcConfig) : null;
|
||||
|
||||
// OIDC 相关的URL
|
||||
export const oidcUrls = {
|
||||
login: `${oidcConfig.authority}/auth`,
|
||||
logout: `${oidcConfig.authority}/logout`,
|
||||
token: `${oidcConfig.authority}/token`,
|
||||
userinfo: `${oidcConfig.authority}/userinfo`,
|
||||
};
|
|
@ -1,130 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import React, { createContext, useContext, useEffect, useState, ReactNode } from 'react';
|
||||
import { User } from 'oidc-client-ts';
|
||||
import { userManager } from '@/lib/oidc-config';
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
isLoading: boolean;
|
||||
isAuthenticated: boolean;
|
||||
login: () => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth必须在AuthProvider内部使用');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
interface AuthProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const isAuthenticated = !!user && !user.expired;
|
||||
|
||||
useEffect(() => {
|
||||
if (!userManager) return;
|
||||
|
||||
const initAuth = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const currentUser = await userManager.getUser();
|
||||
setUser(currentUser);
|
||||
} catch (err) {
|
||||
console.error('初始化认证失败:', err);
|
||||
setError('认证初始化失败');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
initAuth();
|
||||
|
||||
// 监听用户状态变化
|
||||
const handleUserLoaded = (user: User) => {
|
||||
setUser(user);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const handleUserUnloaded = () => {
|
||||
setUser(null);
|
||||
};
|
||||
|
||||
const handleAccessTokenExpired = () => {
|
||||
setUser(null);
|
||||
setError('访问令牌已过期');
|
||||
};
|
||||
|
||||
const handleSilentRenewError = (error: Error) => {
|
||||
console.error('静默续约失败:', error);
|
||||
setError('令牌续约失败');
|
||||
};
|
||||
|
||||
userManager.events.addUserLoaded(handleUserLoaded);
|
||||
userManager.events.addUserUnloaded(handleUserUnloaded);
|
||||
userManager.events.addAccessTokenExpired(handleAccessTokenExpired);
|
||||
userManager.events.addSilentRenewError(handleSilentRenewError);
|
||||
|
||||
return () => {
|
||||
if (userManager) {
|
||||
userManager.events.removeUserLoaded(handleUserLoaded);
|
||||
userManager.events.removeUserUnloaded(handleUserUnloaded);
|
||||
userManager.events.removeAccessTokenExpired(handleAccessTokenExpired);
|
||||
userManager.events.removeSilentRenewError(handleSilentRenewError);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const login = async () => {
|
||||
if (!userManager) {
|
||||
setError('用户管理器未初始化');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setError(null);
|
||||
await userManager.signinRedirect();
|
||||
} catch (err) {
|
||||
console.error('登录失败:', err);
|
||||
setError('登录失败');
|
||||
}
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
if (!userManager) {
|
||||
setError('用户管理器未初始化');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setError(null);
|
||||
await userManager.signoutRedirect();
|
||||
} catch (err) {
|
||||
console.error('登出失败:', err);
|
||||
setError('登出失败');
|
||||
}
|
||||
};
|
||||
|
||||
const value: AuthContextType = {
|
||||
user,
|
||||
isLoading,
|
||||
isAuthenticated,
|
||||
login,
|
||||
logout,
|
||||
error,
|
||||
};
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
};
|
|
@ -1,158 +0,0 @@
|
|||
# OIDC Provider
|
||||
|
||||
OpenID Connect Provider 实现,支持标准的 OIDC 协议流程。
|
||||
|
||||
## 特性
|
||||
|
||||
- 完整的 OIDC 协议支持
|
||||
- 密码认证策略
|
||||
- 会话管理
|
||||
- 令牌管理(访问令牌、刷新令牌、ID令牌)
|
||||
- PKCE 支持
|
||||
- 可自定义的存储适配器
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 安装
|
||||
|
||||
```bash
|
||||
npm install @nice/oidc-provider
|
||||
```
|
||||
|
||||
### 2. 配置
|
||||
|
||||
```typescript
|
||||
import { createOIDCProvider } from '@nice/oidc-provider/middleware/hono';
|
||||
import { MemoryStorageAdapter } from '@nice/oidc-provider/storage';
|
||||
|
||||
const config = {
|
||||
issuer: 'https://your-domain.com',
|
||||
signingKey: 'your-signing-key',
|
||||
storage: new MemoryStorageAdapter(),
|
||||
|
||||
// 用户和客户端查找函数
|
||||
findUser: async (userId: string) => {
|
||||
// 从数据库查找用户
|
||||
return await db.user.findUnique({ where: { id: userId } });
|
||||
},
|
||||
|
||||
findClient: async (clientId: string) => {
|
||||
// 从数据库查找客户端
|
||||
return await db.client.findUnique({ where: { id: clientId } });
|
||||
},
|
||||
|
||||
// 认证配置
|
||||
authConfig: {
|
||||
// 密码验证器
|
||||
passwordValidator: async (username: string, password: string) => {
|
||||
const user = await db.user.findUnique({ where: { username } });
|
||||
if (user && await bcrypt.compare(password, user.hashedPassword)) {
|
||||
return user.id;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
// 会话配置
|
||||
sessionTTL: 24 * 60 * 60, // 24小时
|
||||
rememberMeMaxAge: 30 * 24 * 60 * 60, // 30天
|
||||
|
||||
// 页面配置
|
||||
pageConfig: {
|
||||
title: '用户登录',
|
||||
brandName: '我的应用',
|
||||
logoUrl: '/logo.png'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 创建 OIDC Provider Hono 应用
|
||||
const oidcApp = createOIDCProvider(config);
|
||||
```
|
||||
|
||||
### 3. 集成到 Hono 应用
|
||||
|
||||
```typescript
|
||||
import { Hono } from 'hono';
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
// 挂载 OIDC Provider
|
||||
app.route('/oidc', oidcApp);
|
||||
|
||||
export default app;
|
||||
```
|
||||
|
||||
## API 端点
|
||||
|
||||
创建后的 OIDC Provider 将提供以下标准端点:
|
||||
|
||||
- `POST /login` - 用户登录
|
||||
- `GET /logout` - 用户登出
|
||||
- `POST /logout` - 用户登出(POST 方式)
|
||||
- `GET /.well-known/openid-configuration` - OIDC 发现文档
|
||||
- `GET /.well-known/jwks.json` - JSON Web Key Set
|
||||
- `GET /auth` - 授权端点
|
||||
- `POST /token` - 令牌端点
|
||||
- `GET /userinfo` - 用户信息端点
|
||||
- `POST /revoke` - 令牌撤销端点
|
||||
- `POST /introspect` - 令牌内省端点
|
||||
|
||||
## 配置选项
|
||||
|
||||
### OIDCProviderConfig
|
||||
|
||||
| 字段 | 类型 | 必需 | 描述 |
|
||||
|------|------|------|------|
|
||||
| `issuer` | string | ✓ | 发行者标识符 |
|
||||
| `signingKey` | string | ✓ | JWT 签名密钥 |
|
||||
| `storage` | StorageAdapter | ✓ | 存储适配器 |
|
||||
| `findUser` | function | ✓ | 用户查找函数 |
|
||||
| `findClient` | function | ✓ | 客户端查找函数 |
|
||||
| `authConfig` | AuthConfig | - | 认证配置 |
|
||||
| `tokenTTL` | TokenTTLConfig | - | 令牌过期时间配置 |
|
||||
|
||||
### AuthConfig
|
||||
|
||||
| 字段 | 类型 | 必需 | 描述 |
|
||||
|------|------|------|------|
|
||||
| `passwordValidator` | function | - | 密码验证函数 |
|
||||
| `sessionTTL` | number | - | 会话过期时间(秒) |
|
||||
| `rememberMeMaxAge` | number | - | 记住我最长时间(秒) |
|
||||
| `pageConfig` | PageConfig | - | 登录页面配置 |
|
||||
|
||||
### PageConfig
|
||||
|
||||
| 字段 | 类型 | 描述 |
|
||||
|------|------|------|
|
||||
| `title` | string | 登录页面标题 |
|
||||
| `brandName` | string | 品牌名称 |
|
||||
| `logoUrl` | string | Logo URL |
|
||||
|
||||
## 存储适配器
|
||||
|
||||
项目提供了多种存储适配器:
|
||||
|
||||
- `MemoryStorageAdapter` - 内存存储(适用于开发和测试)
|
||||
- `RedisStorageAdapter` - Redis 存储
|
||||
- `DatabaseStorageAdapter` - 数据库存储
|
||||
|
||||
### 自定义存储适配器
|
||||
|
||||
```typescript
|
||||
import { StorageAdapter } from '@nice/oidc-provider/storage';
|
||||
|
||||
class CustomStorageAdapter implements StorageAdapter {
|
||||
// 实现所需的方法
|
||||
}
|
||||
```
|
||||
|
||||
## 安全考虑
|
||||
|
||||
1. **签名密钥安全**:确保 `signingKey` 足够复杂且妥善保管
|
||||
2. **HTTPS**:生产环境必须使用 HTTPS
|
||||
3. **客户端验证**:实现严格的客户端验证逻辑
|
||||
4. **密码策略**:在 `passwordValidator` 中实现适当的密码策略
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT
|
|
@ -0,0 +1,89 @@
|
|||
import { OIDCProvider } from '../src';
|
||||
import type { OIDCProviderConfig } from '../src/types';
|
||||
|
||||
// 示例:使用RS256算法自动生成密钥对
|
||||
const configWithRS256: OIDCProviderConfig = {
|
||||
issuer: 'https://your-auth-server.com',
|
||||
signingKey: 'temporary-key', // 这个字符串会被自动生成的RSA密钥对替代
|
||||
signingAlgorithm: 'RS256', // 指定使用RSA算法
|
||||
storage: {} as any, // 这里应该是真实的存储适配器
|
||||
findUser: async (userId: string) => null,
|
||||
findClient: async (clientId: string) => null,
|
||||
authConfig: {
|
||||
passwordValidator: async (username: string, password: string) => null,
|
||||
},
|
||||
};
|
||||
|
||||
// 示例:使用ES256算法自动生成密钥对
|
||||
const configWithES256: OIDCProviderConfig = {
|
||||
issuer: 'https://your-auth-server.com',
|
||||
signingKey: 'temporary-key', // 这个字符串会被自动生成的ECDSA密钥对替代
|
||||
signingAlgorithm: 'ES256', // 指定使用ECDSA算法
|
||||
storage: {} as any,
|
||||
findUser: async (userId: string) => null,
|
||||
findClient: async (clientId: string) => null,
|
||||
authConfig: {
|
||||
passwordValidator: async (username: string, password: string) => null,
|
||||
},
|
||||
};
|
||||
|
||||
// 示例:使用HS256算法(不会自动生成密钥对)
|
||||
const configWithHS256: OIDCProviderConfig = {
|
||||
issuer: 'https://your-auth-server.com',
|
||||
signingKey: 'your-secret-key', // 对于HMAC,直接使用字符串密钥
|
||||
signingAlgorithm: 'HS256',
|
||||
storage: {} as any,
|
||||
findUser: async (userId: string) => null,
|
||||
findClient: async (clientId: string) => null,
|
||||
authConfig: {
|
||||
passwordValidator: async (username: string, password: string) => null,
|
||||
},
|
||||
};
|
||||
|
||||
// 使用示例
|
||||
async function demonstrateAutoKeyGeneration() {
|
||||
console.log('=== 自动密钥生成示例 ===\n');
|
||||
|
||||
// RS256 示例
|
||||
console.log('1. 创建使用RS256算法的Provider:');
|
||||
const providerRS256 = new OIDCProvider(configWithRS256);
|
||||
|
||||
// 第一次调用会触发RSA密钥对生成
|
||||
console.log('获取JWKS (会自动生成RSA密钥对):');
|
||||
const jwksRS256 = await providerRS256.getJWKS();
|
||||
console.log('RSA JWKS keys数量:', jwksRS256.keys.length);
|
||||
console.log('RSA 密钥类型:', jwksRS256.keys[0]?.kty);
|
||||
console.log('RSA 算法:', jwksRS256.keys[0]?.alg);
|
||||
console.log('');
|
||||
|
||||
// ES256 示例
|
||||
console.log('2. 创建使用ES256算法的Provider:');
|
||||
const providerES256 = new OIDCProvider(configWithES256);
|
||||
|
||||
// 第一次调用会触发ECDSA密钥对生成
|
||||
console.log('获取JWKS (会自动生成ECDSA密钥对):');
|
||||
const jwksES256 = await providerES256.getJWKS();
|
||||
console.log('ECDSA JWKS keys数量:', jwksES256.keys.length);
|
||||
console.log('ECDSA 密钥类型:', jwksES256.keys[0]?.kty);
|
||||
console.log('ECDSA 算法:', jwksES256.keys[0]?.alg);
|
||||
console.log('');
|
||||
|
||||
// HS256 示例
|
||||
console.log('3. 创建使用HS256算法的Provider:');
|
||||
const providerHS256 = new OIDCProvider(configWithHS256);
|
||||
|
||||
// HS256不会生成JWKS
|
||||
console.log('获取JWKS (HS256不暴露密钥):');
|
||||
const jwksHS256 = await providerHS256.getJWKS();
|
||||
console.log('HS256 JWKS keys数量:', jwksHS256.keys.length);
|
||||
console.log('');
|
||||
|
||||
console.log('=== 示例完成 ===');
|
||||
}
|
||||
|
||||
// 如果直接运行此文件
|
||||
if (require.main === module) {
|
||||
demonstrateAutoKeyGeneration().catch(console.error);
|
||||
}
|
||||
|
||||
export { demonstrateAutoKeyGeneration };
|
|
@ -0,0 +1,91 @@
|
|||
# OIDC Provider - 自动生成密钥对示例
|
||||
|
||||
现在OIDC Provider支持为RSA和ECDSA算法自动生成密钥对,无需手动提供。
|
||||
|
||||
## 基本用法
|
||||
|
||||
### 使用RSA算法(自动生成密钥对)
|
||||
|
||||
```typescript
|
||||
import { OIDCProvider } from '@your-package/oidc-provider';
|
||||
|
||||
// 直接使用构造函数创建Provider实例
|
||||
const provider = new OIDCProvider({
|
||||
issuer: 'https://auth.example.com',
|
||||
signingAlgorithm: 'RS256', // 指定算法,密钥对将在首次使用时自动生成
|
||||
storage: storageAdapter,
|
||||
findUser: async (userId) => { /* 查找用户逻辑 */ },
|
||||
findClient: async (clientId) => { /* 查找客户端逻辑 */ },
|
||||
authConfig: {
|
||||
passwordValidator: async (username, password) => {
|
||||
// 验证用户名密码,返回用户ID或null
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### 使用ECDSA算法(自动生成密钥对)
|
||||
|
||||
```typescript
|
||||
const provider = new OIDCProvider({
|
||||
issuer: 'https://auth.example.com',
|
||||
signingAlgorithm: 'ES256', // ECDSA算法,密钥对将在首次使用时自动生成
|
||||
storage: storageAdapter,
|
||||
findUser: async (userId) => { /* 查找用户逻辑 */ },
|
||||
findClient: async (clientId) => { /* 查找客户端逻辑 */ },
|
||||
authConfig: {
|
||||
passwordValidator: async (username, password) => {
|
||||
// 验证用户名密码,返回用户ID或null
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### 使用HMAC算法(需要提供密钥)
|
||||
|
||||
```typescript
|
||||
const provider = new OIDCProvider({
|
||||
issuer: 'https://auth.example.com',
|
||||
signingKey: 'your-secret-key', // HS256必须提供密钥
|
||||
signingAlgorithm: 'HS256', // 可选,默认为HS256
|
||||
storage: storageAdapter,
|
||||
findUser: async (userId) => { /* 查找用户逻辑 */ },
|
||||
findClient: async (clientId) => { /* 查找客户端逻辑 */ },
|
||||
authConfig: {
|
||||
passwordValidator: async (username, password) => {
|
||||
// 验证用户名密码,返回用户ID或null
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## 密钥生成时机
|
||||
|
||||
- **懒加载**:密钥对将在首次调用需要签名的方法时自动生成(如生成token、获取JWKS等)
|
||||
- **一次生成**:每个Provider实例只会生成一次密钥对,后续调用会复用相同的密钥
|
||||
- **控制台输出**:自动生成密钥对时会在控制台输出确认信息
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **生产环境建议**:在生产环境中,建议提前生成并持久化密钥对,而不是每次启动时重新生成
|
||||
2. **HS256算法**:使用HS256时仍然需要提供`signingKey`
|
||||
3. **同步构造**:现在可以直接使用`new OIDCProvider()`构造函数,无需异步等待
|
||||
4. **密钥轮换**:如果需要密钥轮换,可以使用`JWTUtils.generateRSAKeyPair()`或`JWTUtils.generateECDSAKeyPair()`方法生成新的密钥对
|
||||
|
||||
## 手动提供密钥对
|
||||
|
||||
如果你想手动提供密钥对:
|
||||
|
||||
```typescript
|
||||
import { JWTUtils } from '@your-package/oidc-provider';
|
||||
|
||||
// 生成密钥对
|
||||
const keyPair = await JWTUtils.generateRSAKeyPair('my-key-id');
|
||||
|
||||
const provider = new OIDCProvider({
|
||||
issuer: 'https://auth.example.com',
|
||||
signingKey: keyPair, // 手动提供密钥对
|
||||
signingAlgorithm: 'RS256',
|
||||
// ... 其他配置
|
||||
});
|
||||
```
|
|
@ -0,0 +1,121 @@
|
|||
import { OIDCProvider } from '../src/provider';
|
||||
import type { OIDCProviderConfig } from '../src/types';
|
||||
|
||||
// 模拟存储适配器
|
||||
const mockStorage = {
|
||||
async set(key: string, value: any, ttl?: number): Promise<void> {
|
||||
console.log(`存储: ${key}`);
|
||||
},
|
||||
async get(key: string): Promise<any> {
|
||||
return null;
|
||||
},
|
||||
async delete(key: string): Promise<void> {
|
||||
console.log(`删除: ${key}`);
|
||||
}
|
||||
};
|
||||
|
||||
// 基础配置
|
||||
const baseConfig: Omit<OIDCProviderConfig, 'signingKey' | 'signingAlgorithm'> = {
|
||||
issuer: 'https://auth.example.com',
|
||||
storage: mockStorage,
|
||||
findUser: async (userId: string) => ({
|
||||
sub: userId,
|
||||
username: 'testuser',
|
||||
email: 'test@example.com'
|
||||
}),
|
||||
findClient: async (clientId: string) => ({
|
||||
client_id: clientId,
|
||||
client_type: 'public' as const,
|
||||
redirect_uris: ['http://localhost:3000/callback'],
|
||||
grant_types: ['authorization_code'],
|
||||
response_types: ['code'],
|
||||
scopes: ['openid', 'profile'],
|
||||
created_at: new Date(),
|
||||
updated_at: new Date()
|
||||
}),
|
||||
authConfig: {
|
||||
passwordValidator: async (username: string, password: string) => {
|
||||
return username === 'test' && password === 'password' ? 'user123' : null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
async function testAutoKeyGeneration() {
|
||||
console.log('=== 测试自动生成密钥对功能 ===\n');
|
||||
|
||||
// 测试1: RS256算法自动生成RSA密钥对
|
||||
console.log('1. 测试RS256算法自动生成RSA密钥对:');
|
||||
try {
|
||||
const providerRS256 = new OIDCProvider({
|
||||
...baseConfig,
|
||||
signingAlgorithm: 'RS256'
|
||||
// 注意:没有提供signingKey
|
||||
});
|
||||
|
||||
const jwks = await providerRS256.getJWKS();
|
||||
console.log('✅ 成功生成RS256密钥对');
|
||||
console.log('JWKS keys count:', jwks.keys.length);
|
||||
console.log('First key algorithm:', jwks.keys[0]?.alg);
|
||||
console.log('');
|
||||
} catch (error) {
|
||||
console.error('❌ RS256测试失败:', error);
|
||||
}
|
||||
|
||||
// 测试2: ES256算法自动生成ECDSA密钥对
|
||||
console.log('2. 测试ES256算法自动生成ECDSA密钥对:');
|
||||
try {
|
||||
const providerES256 = new OIDCProvider({
|
||||
...baseConfig,
|
||||
signingAlgorithm: 'ES256'
|
||||
// 注意:没有提供signingKey
|
||||
});
|
||||
|
||||
const jwks = await providerES256.getJWKS();
|
||||
console.log('✅ 成功生成ES256密钥对');
|
||||
console.log('JWKS keys count:', jwks.keys.length);
|
||||
console.log('First key algorithm:', jwks.keys[0]?.alg);
|
||||
console.log('');
|
||||
} catch (error) {
|
||||
console.error('❌ ES256测试失败:', error);
|
||||
}
|
||||
|
||||
// 测试3: HS256算法没有signingKey应该失败
|
||||
console.log('3. 测试HS256算法没有signingKey应该失败:');
|
||||
try {
|
||||
const providerHS256 = new OIDCProvider({
|
||||
...baseConfig,
|
||||
signingAlgorithm: 'HS256'
|
||||
// 注意:没有提供signingKey,应该失败
|
||||
});
|
||||
|
||||
// 调用getJWKS触发验证
|
||||
await providerHS256.getJWKS();
|
||||
console.error('❌ HS256测试失败:应该抛出错误但没有');
|
||||
} catch (error) {
|
||||
console.log('✅ HS256测试成功:正确抛出错误');
|
||||
console.log('错误信息:', (error as Error).message);
|
||||
console.log('');
|
||||
}
|
||||
|
||||
// 测试4: HS256算法提供signingKey应该成功
|
||||
console.log('4. 测试HS256算法提供signingKey应该成功:');
|
||||
try {
|
||||
const providerHS256 = new OIDCProvider({
|
||||
...baseConfig,
|
||||
signingKey: 'my-secret-key-at-least-32-characters-long',
|
||||
signingAlgorithm: 'HS256'
|
||||
});
|
||||
|
||||
const jwks = await providerHS256.getJWKS();
|
||||
console.log('✅ HS256测试成功');
|
||||
console.log('JWKS keys count:', jwks.keys.length, '(HS256不公开密钥)');
|
||||
console.log('');
|
||||
} catch (error) {
|
||||
console.error('❌ HS256测试失败:', error);
|
||||
}
|
||||
|
||||
console.log('=== 测试完成 ===');
|
||||
}
|
||||
|
||||
// 运行测试
|
||||
testAutoKeyGeneration().catch(console.error);
|
|
@ -0,0 +1,144 @@
|
|||
import { OIDCProvider } from '../src/provider';
|
||||
import { MemoryStorageAdapter } from '../src/storage/memory';
|
||||
import type { OIDCProviderConfig, OIDCUser, OIDCClient } from '../src/types';
|
||||
|
||||
// 示例:带有zod验证的OIDC Provider使用
|
||||
|
||||
// 创建存储适配器
|
||||
const storage = new MemoryStorageAdapter();
|
||||
|
||||
// 示例用户查找函数
|
||||
const findUser = async (userId: string): Promise<OIDCUser | null> => {
|
||||
const users: Record<string, OIDCUser> = {
|
||||
'user123': {
|
||||
sub: 'user123',
|
||||
username: 'john@example.com',
|
||||
email: 'john@example.com',
|
||||
email_verified: true,
|
||||
name: 'John Doe',
|
||||
given_name: 'John',
|
||||
family_name: 'Doe',
|
||||
}
|
||||
};
|
||||
return users[userId] || null;
|
||||
};
|
||||
|
||||
// 示例客户端查找函数
|
||||
const findClient = async (clientId: string): Promise<OIDCClient | null> => {
|
||||
const clients: Record<string, OIDCClient> = {
|
||||
'demo-client': {
|
||||
client_id: 'demo-client',
|
||||
client_secret: 'demo-secret',
|
||||
client_name: 'Demo Application',
|
||||
client_type: 'confidential',
|
||||
redirect_uris: ['https://app.example.com/callback'],
|
||||
grant_types: ['authorization_code', 'refresh_token'],
|
||||
response_types: ['code'],
|
||||
scopes: ['openid', 'profile', 'email'],
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
}
|
||||
};
|
||||
return clients[clientId] || null;
|
||||
};
|
||||
|
||||
// 密码验证器
|
||||
const passwordValidator = async (username: string, password: string): Promise<string | null> => {
|
||||
// 这里应该实现真实的密码验证逻辑
|
||||
if (username === 'john@example.com' && password === 'password123') {
|
||||
return 'user123'; // 返回用户ID
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// 配置OIDC Provider
|
||||
const config: OIDCProviderConfig = {
|
||||
issuer: 'https://auth.example.com',
|
||||
signingKey: 'your-secret-key-for-development-only',
|
||||
signingAlgorithm: 'HS256',
|
||||
storage,
|
||||
findUser,
|
||||
findClient,
|
||||
authConfig: {
|
||||
passwordValidator,
|
||||
sessionTTL: 3600, // 1小时
|
||||
pageConfig: {
|
||||
title: 'My Auth Server',
|
||||
brandName: 'Example Corp',
|
||||
logoUrl: 'https://example.com/logo.png',
|
||||
},
|
||||
rememberMeMaxAge: 30 * 24 * 3600, // 30天
|
||||
},
|
||||
tokenTTL: {
|
||||
accessToken: 3600, // 1小时
|
||||
refreshToken: 30 * 24 * 3600, // 30天
|
||||
authorizationCode: 600, // 10分钟
|
||||
idToken: 3600, // 1小时
|
||||
},
|
||||
enablePKCE: true,
|
||||
requirePKCE: true, // 对公共客户端强制要求PKCE
|
||||
rotateRefreshTokens: true,
|
||||
};
|
||||
|
||||
// 创建OIDC Provider实例
|
||||
const provider = new OIDCProvider(config);
|
||||
|
||||
// 导出配置好的provider
|
||||
export { provider };
|
||||
|
||||
// 使用示例:
|
||||
// 1. 授权请求会自动使用zod验证所有参数
|
||||
// 2. 令牌请求会验证FormData和Basic认证头
|
||||
// 3. 用户信息请求会验证Bearer token格式
|
||||
// 4. 令牌撤销和内省请求会验证相应的参数
|
||||
|
||||
// 错误处理示例:
|
||||
export const handleAuthorizationExample = async (query: Record<string, string>) => {
|
||||
try {
|
||||
// 这会触发zod验证
|
||||
const result = await provider.handleAuthorizationRequest({
|
||||
response_type: query.response_type,
|
||||
client_id: query.client_id,
|
||||
redirect_uri: query.redirect_uri,
|
||||
scope: query.scope,
|
||||
state: query.state,
|
||||
// ... 其他参数
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
console.log('授权码:', result.code);
|
||||
} else {
|
||||
console.error('授权失败:', result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('授权请求参数无效')) {
|
||||
console.error('参数验证失败:', error.message);
|
||||
} else {
|
||||
console.error('未知错误:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 令牌请求示例
|
||||
export const handleTokenExample = async (formData: FormData) => {
|
||||
try {
|
||||
// 这会触发zod验证FormData
|
||||
const result = await provider.handleTokenRequest({
|
||||
grant_type: formData.get('grant_type')?.toString() || '',
|
||||
client_id: formData.get('client_id')?.toString() || '',
|
||||
// ... 其他参数会被自动验证
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
console.log('访问令牌:', result.response.access_token);
|
||||
} else {
|
||||
console.error('令牌请求失败:', result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('令牌请求参数无效')) {
|
||||
console.error('参数验证失败:', error.message);
|
||||
} else {
|
||||
console.error('未知错误:', error);
|
||||
}
|
||||
}
|
||||
};
|
|
@ -2,8 +2,9 @@
|
|||
"name": "@repo/oidc-provider",
|
||||
"version": "2.0.0",
|
||||
"description": "OpenID Connect Provider implementation for Hono - 完全兼容 Hono 的 OIDC Provider",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch",
|
||||
|
@ -25,13 +26,6 @@
|
|||
"hono": "^4.0.0",
|
||||
"ioredis": "^5.0.0"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md"
|
||||
|
|
|
@ -1,109 +0,0 @@
|
|||
import type { OIDCError } from '../types';
|
||||
|
||||
/**
|
||||
* OIDC错误处理工厂类
|
||||
* 用于创建标准化的OIDC错误响应
|
||||
*/
|
||||
export class OIDCErrorFactory {
|
||||
/**
|
||||
* 创建授权错误(带可选的重定向URI和state)
|
||||
*/
|
||||
static createAuthError(error: string, description: string, state?: string) {
|
||||
return {
|
||||
success: false as const,
|
||||
error: { error, error_description: description, state },
|
||||
redirectUri: undefined as string | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建令牌错误
|
||||
*/
|
||||
static createTokenError(error: string, description: string) {
|
||||
return {
|
||||
success: false as const,
|
||||
error: { error, error_description: description },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建简单错误(用于一般API响应)
|
||||
*/
|
||||
static createSimpleError(error: string, description: string): OIDCError {
|
||||
return { error, error_description: description };
|
||||
}
|
||||
|
||||
/**
|
||||
* 服务器错误
|
||||
*/
|
||||
static serverError(state?: string) {
|
||||
return this.createAuthError('server_error', 'Internal server error', state);
|
||||
}
|
||||
|
||||
/**
|
||||
* 无效令牌错误
|
||||
*/
|
||||
static invalidToken(description = 'Invalid token') {
|
||||
return this.createSimpleError('invalid_token', description);
|
||||
}
|
||||
|
||||
/**
|
||||
* 无效请求错误
|
||||
*/
|
||||
static invalidRequest(description: string) {
|
||||
return this.createSimpleError('invalid_request', description);
|
||||
}
|
||||
|
||||
/**
|
||||
* 无效客户端错误
|
||||
*/
|
||||
static invalidClient(description: string) {
|
||||
return this.createTokenError('invalid_client', description);
|
||||
}
|
||||
|
||||
/**
|
||||
* 无效授权错误
|
||||
*/
|
||||
static invalidGrant(description: string) {
|
||||
return this.createTokenError('invalid_grant', description);
|
||||
}
|
||||
|
||||
/**
|
||||
* 不支持的授权类型错误
|
||||
*/
|
||||
static unsupportedGrantType(description = 'Grant type not supported') {
|
||||
return this.createTokenError('unsupported_grant_type', description);
|
||||
}
|
||||
|
||||
/**
|
||||
* 无效作用域错误
|
||||
*/
|
||||
static invalidScope(description: string) {
|
||||
return this.createTokenError('invalid_scope', description);
|
||||
}
|
||||
|
||||
/**
|
||||
* 需要登录错误
|
||||
*/
|
||||
static loginRequired(description = 'User authentication is required', state?: string) {
|
||||
return this.createAuthError('login_required', description, state);
|
||||
}
|
||||
|
||||
/**
|
||||
* PKCE相关错误
|
||||
*/
|
||||
static pkceError(description: string, state?: string) {
|
||||
return this.createAuthError('invalid_request', description, state);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建错误响应的URL参数
|
||||
*/
|
||||
static buildErrorResponse(error: OIDCError): URLSearchParams {
|
||||
const params = new URLSearchParams();
|
||||
Object.entries(error).forEach(([key, value]) => {
|
||||
if (value != null) params.set(key, String(value));
|
||||
});
|
||||
return params;
|
||||
}
|
||||
}
|
|
@ -1 +0,0 @@
|
|||
export { OIDCErrorFactory } from './error-factory';
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,110 @@
|
|||
import { z } from 'zod';
|
||||
export const codeChallengeMethods = z.enum(['plain', 'S256']);
|
||||
|
||||
// 授权请求Schema
|
||||
export const authorizationRequestSchema = z.object({
|
||||
response_type: z.string().min(1, '响应类型不能为空'),
|
||||
client_id: z.string().min(1, '客户端ID不能为空'),
|
||||
redirect_uri: z.string().url('重定向URI必须是有效的URL'),
|
||||
scope: z.string().min(1, '作用域不能为空'),
|
||||
state: z.string().optional(),
|
||||
nonce: z.string().optional(),
|
||||
code_challenge: z.string().optional(),
|
||||
code_challenge_method: codeChallengeMethods.optional(),
|
||||
prompt: z.string().optional(),
|
||||
max_age: z.number().int().positive().optional(),
|
||||
id_token_hint: z.string().optional(),
|
||||
login_hint: z.string().optional(),
|
||||
acr_values: z.string().optional(),
|
||||
}).strict();
|
||||
|
||||
// 令牌请求Schema
|
||||
export const tokenRequestSchema = z.object({
|
||||
grant_type: z.string().min(1, '授权类型不能为空'),
|
||||
code: z.string().optional(),
|
||||
redirect_uri: z.string().url('重定向URI必须是有效的URL').optional().or(z.literal('')),
|
||||
client_id: z.string().min(1, '客户端ID不能为空'),
|
||||
client_secret: z.string().optional(),
|
||||
refresh_token: z.string().optional(),
|
||||
scope: z.string().optional(),
|
||||
code_verifier: z.string().optional(),
|
||||
}).strict();
|
||||
|
||||
|
||||
|
||||
|
||||
// 令牌撤销请求Schema
|
||||
export const revokeTokenRequestSchema = z.object({
|
||||
token: z.string().min(1, '令牌不能为空'),
|
||||
token_type_hint: z.enum(['access_token', 'refresh_token']).optional(),
|
||||
client_id: z.string().optional(),
|
||||
}).strict();
|
||||
|
||||
// 令牌内省请求Schema
|
||||
export const introspectTokenRequestSchema = z.object({
|
||||
token: z.string().min(1, '令牌不能为空'),
|
||||
token_type_hint: z.enum(['access_token', 'refresh_token']).optional(),
|
||||
client_id: z.string().optional(),
|
||||
}).strict();
|
||||
|
||||
|
||||
|
||||
// 查询参数解析Schema(用于解析URL参数)
|
||||
export const authorizationQuerySchema = z.record(z.string(), z.union([z.string(), z.array(z.string())])).transform((data) => {
|
||||
// 将数组参数转换为单个字符串(取第一个值)
|
||||
const normalized: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
if (Array.isArray(value)) {
|
||||
// 处理数组,取第一个值,如果为空则设为空字符串
|
||||
normalized[key] = value[0] || '';
|
||||
} else {
|
||||
// 处理字符串值
|
||||
normalized[key] = value || '';
|
||||
}
|
||||
}
|
||||
return normalized;
|
||||
});
|
||||
|
||||
// Form data解析Schema
|
||||
export const tokenFormDataSchema = z.instanceof(FormData).transform((formData) => {
|
||||
const result: Record<string, string> = {};
|
||||
for (const [key, value] of formData.entries()) {
|
||||
// 只处理字符串值,忽略File类型
|
||||
if (typeof value === 'string') {
|
||||
result[key] = value;
|
||||
} else if (value instanceof File) {
|
||||
// 如果是文件,将文件名作为值(通常不应该在token请求中出现)
|
||||
result[key] = value.name || '';
|
||||
}
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
// HTTP Authorization header Schema
|
||||
export const bearerTokenSchema = z.string().regex(/^Bearer\s+(.+)$/, '无效的Bearer令牌格式').transform((auth) => {
|
||||
return auth.replace(/^Bearer\s+/, '');
|
||||
});
|
||||
|
||||
export const basicAuthSchema = z.string().regex(/^Basic\s+(.+)$/, '无效的Basic认证格式').transform((auth) => {
|
||||
try {
|
||||
const base64Part = auth.replace(/^Basic\s+/, '');
|
||||
if (!base64Part) {
|
||||
throw new Error('Basic认证缺少凭证部分');
|
||||
}
|
||||
|
||||
const decoded = atob(base64Part);
|
||||
const colonIndex = decoded.indexOf(':');
|
||||
|
||||
if (colonIndex === -1) {
|
||||
// 如果没有冒号,整个字符串作为用户名,密码为空
|
||||
return { username: decoded, password: '' };
|
||||
}
|
||||
|
||||
const username = decoded.substring(0, colonIndex);
|
||||
const password = decoded.substring(colonIndex + 1);
|
||||
return { username, password };
|
||||
} catch (error) {
|
||||
throw new Error(`无效的Basic认证编码: ${error instanceof Error ? error.message : '未知错误'}`);
|
||||
}
|
||||
});
|
||||
|
|
@ -1,12 +1,11 @@
|
|||
import type { StorageAdapter } from '../storage/adapter';
|
||||
import type { KeyPair } from '../utils/jwt';
|
||||
|
||||
export interface OIDCProviderConfig {
|
||||
/** 发行者标识符 */
|
||||
issuer: string;
|
||||
/** 签名密钥 */
|
||||
signingKey: string;
|
||||
/** 签名算法 */
|
||||
signingAlgorithm?: 'HS256' | 'RS256' | 'ES256';
|
||||
/** 签名密钥(HMAC字符串)或密钥对(RSA)- 如果未提供将自动生成RSA密钥对 */
|
||||
signingKey?: string | KeyPair;
|
||||
/** 存储适配器实例(仅用于令牌存储) */
|
||||
storage: StorageAdapter;
|
||||
/** 查找用户的回调函数 */
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { SignJWT, jwtVerify, importJWK, exportJWK, generateKeyPair } from 'jose';
|
||||
import { SignJWT, jwtVerify, exportJWK, generateKeyPair } from 'jose';
|
||||
import type { OIDCUser } from '../types';
|
||||
|
||||
export interface JWTPayload {
|
||||
|
@ -7,36 +7,60 @@ export interface JWTPayload {
|
|||
aud: string | string[];
|
||||
exp: number;
|
||||
iat: number;
|
||||
auth_time?: number;
|
||||
nonce?: string;
|
||||
acr?: string;
|
||||
amr?: string[];
|
||||
azp?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface AccessTokenPayload extends JWTPayload {
|
||||
scope: string;
|
||||
client_id: string;
|
||||
token_type: 'access_token';
|
||||
}
|
||||
|
||||
export interface IDTokenPayload extends JWTPayload {
|
||||
nonce?: string;
|
||||
auth_time: number;
|
||||
[key: string]: any;
|
||||
export interface KeyPair {
|
||||
privateKey: any;
|
||||
publicKey: any;
|
||||
kid: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT工具类
|
||||
*/
|
||||
export class JWTUtils {
|
||||
private signingKey: string;
|
||||
private algorithm: 'HS256' | 'RS256' | 'ES256' = 'HS256';
|
||||
private privateKey: any;
|
||||
private publicKey: any;
|
||||
private kid: string;
|
||||
private keyPromise: Promise<void> | null = null;
|
||||
|
||||
constructor(signingKey: string, algorithm?: 'HS256' | 'RS256' | 'ES256') {
|
||||
this.signingKey = signingKey;
|
||||
this.algorithm = algorithm || 'HS256';
|
||||
constructor(signingKey?: string | KeyPair) {
|
||||
if (typeof signingKey === 'string') {
|
||||
// HMAC密钥
|
||||
const encodedKey = new TextEncoder().encode(signingKey);
|
||||
this.privateKey = this.publicKey = encodedKey;
|
||||
this.kid = 'hmac-key';
|
||||
} else if (signingKey) {
|
||||
// 非对称密钥
|
||||
this.privateKey = signingKey.privateKey;
|
||||
this.publicKey = signingKey.publicKey;
|
||||
this.kid = signingKey.kid;
|
||||
} else {
|
||||
// 延迟生成RSA密钥
|
||||
this.kid = 'auto-generated';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保密钥已初始化
|
||||
*/
|
||||
private async ensureKeys(): Promise<void> {
|
||||
if (this.privateKey) return;
|
||||
|
||||
if (!this.keyPromise) {
|
||||
this.keyPromise = this.generateRSAKeys();
|
||||
}
|
||||
await this.keyPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成RSA密钥
|
||||
*/
|
||||
private async generateRSAKeys(): Promise<void> {
|
||||
const { privateKey, publicKey } = await generateKeyPair('RS256');
|
||||
this.privateKey = privateKey;
|
||||
this.publicKey = publicKey;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -48,24 +72,23 @@ export class JWTUtils {
|
|||
audience: string;
|
||||
clientId: string;
|
||||
scope: string;
|
||||
expiresIn: number;
|
||||
expiresIn?: number;
|
||||
}): Promise<string> {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
await this.ensureKeys();
|
||||
|
||||
const jwtPayload: AccessTokenPayload = {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const exp = now + (payload.expiresIn || 3600); // 默认1小时
|
||||
|
||||
return this.signToken({
|
||||
iss: payload.issuer,
|
||||
sub: payload.subject,
|
||||
aud: payload.audience,
|
||||
exp: now + payload.expiresIn,
|
||||
exp,
|
||||
iat: now,
|
||||
scope: payload.scope,
|
||||
client_id: payload.clientId,
|
||||
token_type: 'access_token',
|
||||
};
|
||||
|
||||
return await new SignJWT(jwtPayload)
|
||||
.setProtectedHeader({ alg: this.algorithm })
|
||||
.sign(new TextEncoder().encode(this.signingKey));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -76,177 +99,101 @@ export class JWTUtils {
|
|||
subject: string;
|
||||
audience: string;
|
||||
user: OIDCUser;
|
||||
authTime: number;
|
||||
authTime?: number;
|
||||
nonce?: string;
|
||||
expiresIn: number;
|
||||
requestedClaims?: string[];
|
||||
expiresIn?: number;
|
||||
}): Promise<string> {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
await this.ensureKeys();
|
||||
|
||||
const jwtPayload: IDTokenPayload = {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const exp = now + (payload.expiresIn || 3600); // 默认1小时
|
||||
|
||||
const claims = {
|
||||
iss: payload.issuer,
|
||||
sub: payload.subject,
|
||||
aud: payload.audience,
|
||||
exp: now + payload.expiresIn,
|
||||
exp,
|
||||
iat: now,
|
||||
auth_time: payload.authTime,
|
||||
auth_time: payload.authTime || now,
|
||||
...this.extractUserClaims(payload.user),
|
||||
};
|
||||
|
||||
// 添加nonce(如果提供)
|
||||
if (payload.nonce) {
|
||||
jwtPayload.nonce = payload.nonce;
|
||||
}
|
||||
if (payload.nonce) claims.nonce = payload.nonce;
|
||||
|
||||
// 添加用户声明
|
||||
this.addUserClaims(jwtPayload, payload.user, payload.requestedClaims);
|
||||
|
||||
return await new SignJWT(jwtPayload)
|
||||
.setProtectedHeader({ alg: this.algorithm })
|
||||
.sign(new TextEncoder().encode(this.signingKey));
|
||||
return this.signToken(claims);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证JWT令牌
|
||||
*/
|
||||
async verifyToken(token: string): Promise<JWTPayload> {
|
||||
try {
|
||||
const { payload } = await jwtVerify(
|
||||
token,
|
||||
new TextEncoder().encode(this.signingKey)
|
||||
);
|
||||
await this.ensureKeys();
|
||||
const { payload } = await jwtVerify(token, this.publicKey);
|
||||
return payload as JWTPayload;
|
||||
} catch (error) {
|
||||
throw new Error(`Invalid token: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解码JWT令牌(不验证签名)
|
||||
*/
|
||||
decodeToken(token: string): JWTPayload {
|
||||
try {
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) {
|
||||
throw new Error('Invalid token format');
|
||||
}
|
||||
|
||||
const payloadPart = parts[1];
|
||||
if (!payloadPart) {
|
||||
throw new Error('Invalid token payload');
|
||||
}
|
||||
|
||||
const payload = JSON.parse(
|
||||
Buffer.from(payloadPart, 'base64url').toString('utf-8')
|
||||
);
|
||||
return payload;
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to decode token: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查令牌是否过期
|
||||
*/
|
||||
isTokenExpired(token: string): boolean {
|
||||
try {
|
||||
const payload = this.decodeToken(token);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
return payload.exp < now;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取令牌剩余有效时间(秒)
|
||||
*/
|
||||
getTokenTTL(token: string): number {
|
||||
try {
|
||||
const payload = this.decodeToken(token);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
return Math.max(0, payload.exp - now);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加用户声明到JWT载荷
|
||||
*/
|
||||
private addUserClaims(
|
||||
payload: IDTokenPayload,
|
||||
user: OIDCUser,
|
||||
requestedClaims?: string[]
|
||||
): void {
|
||||
// 标准声明映射
|
||||
const standardClaims = {
|
||||
name: user.name,
|
||||
given_name: user.given_name,
|
||||
family_name: user.family_name,
|
||||
preferred_username: user.username,
|
||||
profile: user.profile,
|
||||
picture: user.picture,
|
||||
website: user.website,
|
||||
email: user.email,
|
||||
email_verified: user.email_verified,
|
||||
gender: user.gender,
|
||||
birthdate: user.birthdate,
|
||||
zoneinfo: user.zoneinfo,
|
||||
locale: user.locale,
|
||||
phone_number: user.phone_number,
|
||||
phone_number_verified: user.phone_number_verified,
|
||||
address: user.address,
|
||||
updated_at: user.updated_at,
|
||||
};
|
||||
|
||||
// 如果指定了请求的声明,只添加这些声明
|
||||
if (requestedClaims && requestedClaims.length > 0) {
|
||||
for (const claim of requestedClaims) {
|
||||
if (claim in standardClaims && standardClaims[claim as keyof typeof standardClaims] !== undefined) {
|
||||
payload[claim] = standardClaims[claim as keyof typeof standardClaims];
|
||||
} else if (claim in user && user[claim] !== undefined) {
|
||||
payload[claim] = user[claim];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 添加所有可用的标准声明
|
||||
for (const [claim, value] of Object.entries(standardClaims)) {
|
||||
if (value !== undefined) {
|
||||
payload[claim] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成JWKS(JSON Web Key Set)
|
||||
* 生成JWKS
|
||||
*/
|
||||
async generateJWKS(): Promise<{ keys: any[] }> {
|
||||
// 对于HMAC算法,我们不暴露密钥
|
||||
// 这里返回空的JWKS,实际应用中可能需要使用RSA或ECDSA
|
||||
await this.ensureKeys();
|
||||
|
||||
// HMAC密钥不公开
|
||||
if (this.kid === 'hmac-key') {
|
||||
return { keys: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成RSA密钥对(用于生产环境)
|
||||
*/
|
||||
static async generateRSAKeyPair(): Promise<{
|
||||
privateKey: string;
|
||||
publicKey: string;
|
||||
jwk: any;
|
||||
}> {
|
||||
const { privateKey, publicKey } = await generateKeyPair('RS256');
|
||||
const jwk = await exportJWK(publicKey);
|
||||
|
||||
const jwk = await exportJWK(this.publicKey);
|
||||
return {
|
||||
privateKey: JSON.stringify(await exportJWK(privateKey)),
|
||||
publicKey: JSON.stringify(jwk),
|
||||
jwk: {
|
||||
keys: [{
|
||||
...jwk,
|
||||
alg: 'RS256',
|
||||
use: 'sig',
|
||||
kid: 'default',
|
||||
},
|
||||
kid: this.kid,
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 签名令牌
|
||||
*/
|
||||
private async signToken(payload: any): Promise<string> {
|
||||
const algorithm = this.kid === 'hmac-key' ? 'HS256' : 'RS256';
|
||||
const header: any = { alg: algorithm };
|
||||
|
||||
if (algorithm === 'RS256') {
|
||||
header.kid = this.kid;
|
||||
}
|
||||
|
||||
return new SignJWT(payload)
|
||||
.setProtectedHeader(header)
|
||||
.sign(this.privateKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取用户声明
|
||||
*/
|
||||
private extractUserClaims(user: OIDCUser): any {
|
||||
const claims: any = {};
|
||||
|
||||
if (user.name) claims.name = user.name;
|
||||
if (user.given_name) claims.given_name = user.given_name;
|
||||
if (user.family_name) claims.family_name = user.family_name;
|
||||
if (user.username) claims.preferred_username = user.username;
|
||||
if (user.email) claims.email = user.email;
|
||||
if (user.email_verified !== undefined) claims.email_verified = user.email_verified;
|
||||
if (user.picture) claims.picture = user.picture;
|
||||
if (user.phone_number) claims.phone_number = user.phone_number;
|
||||
if (user.phone_number_verified !== undefined) claims.phone_number_verified = user.phone_number_verified;
|
||||
|
||||
return claims;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成RSA密钥对
|
||||
*/
|
||||
static async generateRSAKeyPair(kid: string = 'rsa-key'): Promise<KeyPair> {
|
||||
const { privateKey, publicKey } = await generateKeyPair('RS256');
|
||||
return { privateKey, publicKey, kid };
|
||||
}
|
||||
}
|
|
@ -1,4 +1 @@
|
|||
// 导出所有API schema
|
||||
export * from './oidc';
|
||||
export * from './user';
|
||||
export * from "./generate.schema"
|
|
@ -1,40 +0,0 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
// 授权请求验证模式
|
||||
export const authorizationRequestSchema = z.object({
|
||||
response_type: z.union([
|
||||
z.literal('code'), // 授权码流程
|
||||
z.literal('token'), // 隐式流程 - 仅访问令牌
|
||||
z.literal('id_token'), // 隐式流程 - 仅ID令牌
|
||||
z.literal('token id_token'), // 隐式流程 - 访问令牌和ID令牌
|
||||
z.literal('code id_token'), // 混合流程 - 授权码和ID令牌
|
||||
z.literal('code token'), // 混合流程 - 授权码和访问令牌
|
||||
z.literal('code token id_token'), // 混合流程 - 授权码、访问令牌和ID令牌
|
||||
]),
|
||||
client_id: z.string(),
|
||||
redirect_uri: z.string().url('必须是有效的URL'),
|
||||
state: z.string().optional(),
|
||||
scope: z.string(),
|
||||
code_challenge: z.string().optional(),
|
||||
code_challenge_method: z.enum(['plain', 'S256']).optional(),
|
||||
nonce: z.string().optional(),
|
||||
prompt: z.enum(['none', 'login', 'consent', 'select_account']).optional(),
|
||||
max_age: z.number().optional(),
|
||||
response_mode: z.enum(['query', 'fragment', 'form_post']).optional().default('query'),
|
||||
session_id: z.string().optional(), // 用于静默授权
|
||||
});
|
||||
|
||||
// 授权码生成参数模式
|
||||
export const authorizationCodeParamsSchema = z.object({
|
||||
userId: z.string(),
|
||||
clientId: z.string(),
|
||||
redirectUri: z.string().url(),
|
||||
scope: z.string(),
|
||||
nonce: z.string().optional(),
|
||||
codeChallenge: z.string().optional(),
|
||||
codeChallengeMethod: z.enum(['plain', 'S256']).optional(),
|
||||
});
|
||||
|
||||
// 类型定义
|
||||
export type AuthorizationRequest = z.infer<typeof authorizationRequestSchema>;
|
||||
export type AuthorizationCodeParams = z.infer<typeof authorizationCodeParamsSchema>;
|
|
@ -1,45 +0,0 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
// 客户端注册验证模式
|
||||
export const registerClientSchema = z.object({
|
||||
clientName: z.string().min(1, '客户端名称不能为空'),
|
||||
clientUri: z.string().url('必须是有效的URL').optional(),
|
||||
logoUri: z.string().url('必须是有效的URL').optional(),
|
||||
redirectUris: z.array(z.string().url('必须是有效的URL')).min(1, '至少需要一个重定向URI'),
|
||||
postLogoutRedirectUris: z.array(z.string().url('必须是有效的URL')).optional().default([]),
|
||||
contacts: z.array(z.string().email('必须是有效的电子邮件')).optional().default([]),
|
||||
tokenEndpointAuthMethod: z
|
||||
.enum(['client_secret_basic', 'client_secret_post', 'client_secret_jwt', 'private_key_jwt', 'none'])
|
||||
.default('client_secret_basic'),
|
||||
grantTypes: z
|
||||
.array(z.enum(['authorization_code', 'refresh_token', 'client_credentials']))
|
||||
.default(['authorization_code', 'refresh_token']),
|
||||
responseTypes: z.array(z.enum(['code', 'token', 'id_token'])).default(['code']),
|
||||
scope: z.string().default('openid profile email'),
|
||||
jwksUri: z.string().url('必须是有效的URL').optional(),
|
||||
jwks: z.string().optional(),
|
||||
policyUri: z.string().url('必须是有效的URL').optional(),
|
||||
tosUri: z.string().url('必须是有效的URL').optional(),
|
||||
});
|
||||
|
||||
// 客户端响应模式
|
||||
export const clientResponseSchema = z.object({
|
||||
id: z.string(),
|
||||
clientId: z.string(),
|
||||
clientSecret: z.string().optional(),
|
||||
clientName: z.string(),
|
||||
clientUri: z.string().optional(),
|
||||
logoUri: z.string().optional(),
|
||||
redirectUris: z.array(z.string()),
|
||||
postLogoutRedirectUris: z.array(z.string()),
|
||||
tokenEndpointAuthMethod: z.string(),
|
||||
grantTypes: z.array(z.string()),
|
||||
responseTypes: z.array(z.string()),
|
||||
scope: z.string(),
|
||||
createdTime: z.date(),
|
||||
requirePkce: z.boolean().optional(),
|
||||
});
|
||||
|
||||
// 类型定义
|
||||
export type RegisterClientInput = z.infer<typeof registerClientSchema>;
|
||||
export type ClientResponse = z.infer<typeof clientResponseSchema>;
|
|
@ -1,48 +0,0 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
// 同意请求验证模式
|
||||
export const consentRequestSchema = z.object({
|
||||
client_id: z.string(),
|
||||
redirect_uri: z.string().url(),
|
||||
state: z.string().optional(),
|
||||
scope: z.string().optional().default('openid profile email'),
|
||||
response_type: z.union([
|
||||
z.literal('code'), // 授权码流程
|
||||
z.literal('token'), // 隐式流程 - 仅访问令牌
|
||||
z.literal('id_token'), // 隐式流程 - 仅ID令牌
|
||||
z.literal('token id_token'), // 隐式流程 - 访问令牌和ID令牌
|
||||
z.literal('code id_token'), // 混合流程 - 授权码和ID令牌
|
||||
z.literal('code token'), // 混合流程 - 授权码和访问令牌
|
||||
z.literal('code token id_token'), // 混合流程 - 授权码、访问令牌和ID令牌
|
||||
]).default('code'),
|
||||
response_mode: z.enum(['query', 'fragment', 'form_post']).optional().default('query'),
|
||||
nonce: z.string().optional(),
|
||||
code_challenge: z.string().optional(),
|
||||
code_challenge_method: z.enum(['plain', 'S256']).optional().default('plain'),
|
||||
});
|
||||
|
||||
// 同意表单验证模式
|
||||
export const consentFormSchema = z.object({
|
||||
client_id: z.string(),
|
||||
redirect_uri: z.string().url(),
|
||||
state: z.string().optional(),
|
||||
scope: z.string(),
|
||||
allow: z.boolean(),
|
||||
response_type: z.union([
|
||||
z.literal('code'), // 授权码流程
|
||||
z.literal('token'), // 隐式流程 - 仅访问令牌
|
||||
z.literal('id_token'), // 隐式流程 - 仅ID令牌
|
||||
z.literal('token id_token'), // 隐式流程 - 访问令牌和ID令牌
|
||||
z.literal('code id_token'), // 混合流程 - 授权码和ID令牌
|
||||
z.literal('code token'), // 混合流程 - 授权码和访问令牌
|
||||
z.literal('code token id_token'), // 混合流程 - 授权码、访问令牌和ID令牌
|
||||
]).default('code'),
|
||||
response_mode: z.enum(['query', 'fragment', 'form_post']).optional().default('query'),
|
||||
nonce: z.string().optional(),
|
||||
code_challenge: z.string().optional(),
|
||||
code_challenge_method: z.enum(['plain', 'S256']).optional().default('plain'),
|
||||
});
|
||||
|
||||
// 类型定义
|
||||
export type ConsentRequest = z.infer<typeof consentRequestSchema>;
|
||||
export type ConsentForm = z.infer<typeof consentFormSchema>;
|
|
@ -1,7 +0,0 @@
|
|||
// 导出所有oidc相关schema
|
||||
export * from './authorization.schema';
|
||||
export * from './client.schema';
|
||||
export * from './consent.schema';
|
||||
export * from './session.schema';
|
||||
export * from './token.schema';
|
||||
export * from './userinfo.schema';
|
|
@ -1,19 +0,0 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
// 结束会话请求验证模式
|
||||
export const endSessionSchema = z.object({
|
||||
id_token_hint: z.string().optional(),
|
||||
post_logout_redirect_uri: z.string().url().optional(),
|
||||
state: z.string().optional(),
|
||||
client_id: z.string().optional(), // OIDC规范要求
|
||||
});
|
||||
|
||||
// 会话检查请求验证模式
|
||||
export const checkSessionSchema = z.object({
|
||||
client_id: z.string(),
|
||||
origin: z.string().optional(),
|
||||
});
|
||||
|
||||
// 类型定义
|
||||
export type EndSessionRequest = z.infer<typeof endSessionSchema>;
|
||||
export type CheckSessionRequest = z.infer<typeof checkSessionSchema>;
|
|
@ -1,111 +0,0 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
// 令牌请求验证模式 - 授权码授权
|
||||
export const authorizationCodeTokenRequestSchema = z.object({
|
||||
grant_type: z.literal('authorization_code'),
|
||||
code: z.string(),
|
||||
redirect_uri: z.string().url('必须是有效的URL'),
|
||||
client_id: z.string(),
|
||||
client_secret: z.string().optional(),
|
||||
code_verifier: z.string().optional(),
|
||||
});
|
||||
|
||||
// 令牌请求验证模式 - 刷新令牌
|
||||
export const refreshTokenRequestSchema = z.object({
|
||||
grant_type: z.literal('refresh_token'),
|
||||
refresh_token: z.string(),
|
||||
client_id: z.string(),
|
||||
client_secret: z.string().optional(),
|
||||
scope: z.string().optional(),
|
||||
});
|
||||
|
||||
// 令牌请求验证模式 - 客户端凭证
|
||||
export const clientCredentialsTokenRequestSchema = z.object({
|
||||
grant_type: z.literal('client_credentials'),
|
||||
client_id: z.string(),
|
||||
client_secret: z.string(),
|
||||
scope: z.string().optional(),
|
||||
});
|
||||
|
||||
// 令牌请求验证模式 - 密码授权(可选支持)
|
||||
export const passwordTokenRequestSchema = z.object({
|
||||
grant_type: z.literal('password'),
|
||||
username: z.string(),
|
||||
password: z.string(),
|
||||
client_id: z.string(),
|
||||
client_secret: z.string().optional(),
|
||||
scope: z.string().optional(),
|
||||
});
|
||||
|
||||
// 合并的令牌请求验证模式
|
||||
export const tokenRequestSchema = z.discriminatedUnion('grant_type', [
|
||||
authorizationCodeTokenRequestSchema,
|
||||
refreshTokenRequestSchema,
|
||||
clientCredentialsTokenRequestSchema,
|
||||
passwordTokenRequestSchema,
|
||||
]);
|
||||
|
||||
// 令牌响应模式
|
||||
export const tokenResponseSchema = z.object({
|
||||
access_token: z.string(),
|
||||
token_type: z.string().default('Bearer'),
|
||||
expires_in: z.number(),
|
||||
refresh_token: z.string().optional(),
|
||||
id_token: z.string().optional(),
|
||||
scope: z.string().optional(),
|
||||
});
|
||||
|
||||
// 令牌内省请求模式
|
||||
export const tokenIntrospectionRequestSchema = z.object({
|
||||
token: z.string(),
|
||||
token_type_hint: z.enum(['access_token', 'refresh_token']).optional(),
|
||||
client_id: z.string().optional(),
|
||||
client_secret: z.string().optional(),
|
||||
});
|
||||
|
||||
// 令牌内省响应模式
|
||||
export const tokenIntrospectionResponseSchema = z.object({
|
||||
active: z.boolean(),
|
||||
// 如果active为true,则提供以下信息
|
||||
scope: z.string().optional(),
|
||||
client_id: z.string().optional(),
|
||||
username: z.string().optional(),
|
||||
token_type: z.string().optional(),
|
||||
exp: z.number().optional(),
|
||||
iat: z.number().optional(),
|
||||
nbf: z.number().optional(),
|
||||
sub: z.string().optional(),
|
||||
aud: z.string().optional(),
|
||||
iss: z.string().optional(),
|
||||
jti: z.string().optional(),
|
||||
});
|
||||
|
||||
// 令牌撤销请求模式
|
||||
export const tokenRevocationSchema = z.object({
|
||||
token: z.string(),
|
||||
token_type_hint: z.enum(['access_token', 'refresh_token']).optional(),
|
||||
client_id: z.string(),
|
||||
client_secret: z.string().optional(),
|
||||
});
|
||||
|
||||
// 错误响应模式
|
||||
export const tokenErrorResponseSchema = z.object({
|
||||
error: z.enum([
|
||||
'invalid_request',
|
||||
'invalid_client',
|
||||
'invalid_grant',
|
||||
'unauthorized_client',
|
||||
'unsupported_grant_type',
|
||||
'invalid_scope'
|
||||
]),
|
||||
error_description: z.string().optional(),
|
||||
error_uri: z.string().optional(),
|
||||
});
|
||||
|
||||
// 类型定义
|
||||
export type TokenRequest = z.infer<typeof tokenRequestSchema>;
|
||||
export type TokenResponse = z.infer<typeof tokenResponseSchema>;
|
||||
export type TokenIntrospectionRequest = z.infer<typeof tokenIntrospectionRequestSchema>;
|
||||
export type TokenIntrospectionResponse = z.infer<typeof tokenIntrospectionResponseSchema>;
|
||||
export type TokenRevocationRequest = z.infer<typeof tokenRevocationSchema>;
|
||||
export type TokenErrorResponse = z.infer<typeof tokenErrorResponseSchema>;
|
|
@ -1,28 +0,0 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
// 用户信息响应模式
|
||||
export const userInfoResponseSchema = z.object({
|
||||
sub: z.string(),
|
||||
// 标准OIDC声明
|
||||
iss: z.string().optional(),
|
||||
aud: z.string().optional(),
|
||||
iat: z.number().optional(),
|
||||
auth_time: z.number().optional(),
|
||||
// profile scope
|
||||
name: z.string().optional(),
|
||||
nickname: z.string().optional(),
|
||||
profile: z.string().optional(),
|
||||
picture: z.string().optional(),
|
||||
gender: z.string().optional(),
|
||||
birthdate: z.string().optional(),
|
||||
updated_at: z.number().optional(),
|
||||
// email scope
|
||||
email: z.string().optional(),
|
||||
email_verified: z.boolean().optional(),
|
||||
// phone scope
|
||||
phone_number: z.string().optional(),
|
||||
phone_number_verified: z.boolean().optional(),
|
||||
}).passthrough(); // 允许添加自定义声明
|
||||
|
||||
// 类型定义
|
||||
export type UserInfoResponse = z.infer<typeof userInfoResponseSchema>;
|
|
@ -1 +0,0 @@
|
|||
export * from "./user.schema"
|
|
@ -1,47 +0,0 @@
|
|||
import { z } from "../zod";
|
||||
|
||||
// 密码验证Schema
|
||||
export const signupPasswordSchema = z.string().min(8)
|
||||
.regex(/^(?=.*[A-Za-z])(?=.*\d)/, { message: '密码必须包含至少一个字母和一个数字' })
|
||||
.openapi({ description: '用户密码(至少8个字符,包含字母和数字)' });
|
||||
|
||||
// 用户注册验证Schema
|
||||
export const registerUserSchema = z.object({
|
||||
email: z.string().email().openapi({ description: '用户电子邮箱' }),
|
||||
name: z.string().min(2).openapi({ description: '用户名称' }),
|
||||
password: z.string().min(6).openapi({ description: '用户密码' }),
|
||||
phone: z.string().optional().openapi({ description: '电话号码(可选)' }),
|
||||
avatar: z.string().optional().openapi({ description: '头像URL(可选)' }),
|
||||
}).openapi({ title: 'RegisterUser', description: '用户注册信息' });
|
||||
|
||||
|
||||
// 用户登录验证Schema
|
||||
export const loginUserSchema = z.object({
|
||||
email: z.string().email().openapi({ description: '用户电子邮箱' }),
|
||||
password: z.string().openapi({ description: '用户密码' }),
|
||||
}).openapi({ title: 'LoginUser', description: '用户登录信息' });
|
||||
|
||||
// 用户信息响应Schema
|
||||
export const userResponseSchema = z.object({
|
||||
id: z.string().openapi({ description: '用户ID' }),
|
||||
email: z.string().email().openapi({ description: '用户电子邮箱' }),
|
||||
name: z.string().openapi({ description: '用户名称' }),
|
||||
phone: z.string().optional().nullable().openapi({ description: '电话号码' }),
|
||||
avatar: z.string().optional().nullable().openapi({ description: '头像URL' }),
|
||||
createdTime: z.date().optional().openapi({ description: '创建时间' }),
|
||||
updatedTime: z.date().optional().openapi({ description: '更新时间' }),
|
||||
}).openapi({ title: 'UserResponse', description: '用户信息响应' });
|
||||
|
||||
// 登录响应Schema
|
||||
export const loginResponseSchema = z.object({
|
||||
access_token: z.string().openapi({ description: '访问令牌' }),
|
||||
token_type: z.string().openapi({ description: '令牌类型' }),
|
||||
expires_in: z.number().openapi({ description: '过期时间(秒)' }),
|
||||
user: userResponseSchema,
|
||||
}).openapi({ title: 'LoginResponse', description: '登录成功响应' });
|
||||
|
||||
// 类型导出
|
||||
export type RegisterUserDto = z.infer<typeof registerUserSchema>;
|
||||
export type LoginUserDto = z.infer<typeof loginUserSchema>;
|
||||
export type UserResponse = z.infer<typeof userResponseSchema>;
|
||||
export type LoginResponse = z.infer<typeof loginResponseSchema>;
|
|
@ -1 +0,0 @@
|
|||
|
Loading…
Reference in New Issue