This commit is contained in:
ditiqi 2025-05-29 13:24:38 +08:00
commit 774ae000d6
38 changed files with 1289 additions and 2584 deletions

View File

@ -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 中处理

View File

@ -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';
// 导入新的路由

View File

@ -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 };

View File

@ -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
}

View File

@ -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,
};

View File

@ -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);

View File

@ -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,
};

View File

@ -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>
);
}

View File

@ -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 <></>;
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);

View File

@ -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>
);
}

View File

@ -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`,
};

View File

@ -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>;
};

View File

@ -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

View File

@ -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 };

View File

@ -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',
// ... 其他配置
});
```

View File

@ -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);

View File

@ -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);
}
}
};

View File

@ -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"

View File

@ -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;
}
}

View File

@ -1 +0,0 @@
export { OIDCErrorFactory } from './error-factory';

File diff suppressed because it is too large Load Diff

View File

@ -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 : '未知错误'}`);
}
});

View File

@ -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;
/** 查找用户的回调函数 */

View File

@ -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;
}
}
}
}
/**
* JWKSJSON 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 };
}
}

View File

@ -1,4 +1 @@
// 导出所有API schema
export * from './oidc';
export * from './user';
export * from "./generate.schema"

View File

@ -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>;

View File

@ -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>;

View File

@ -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>;

View File

@ -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';

View File

@ -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>;

View File

@ -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>;

View File

@ -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>;

View File

@ -1 +0,0 @@
export * from "./user.schema"

View File

@ -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>;

View File

@ -1 +0,0 @@