From 6e8489d641a753632bcecd12215fb3762504931c Mon Sep 17 00:00:00 2001 From: longdayi <13477510+longdayilongdayi@user.noreply.gitee.com> Date: Thu, 29 May 2025 12:23:29 +0800 Subject: [PATCH] 05291223 --- apps/backend/src/index.ts | 8 +- apps/backend/src/oidc-demo.ts | 134 --- apps/backend/src/oidc/auth.ts | 11 + apps/backend/src/oidc/clients.ts | 47 + apps/backend/src/oidc/index.ts | 44 + apps/backend/src/oidc/users.ts | 42 + apps/backend/src/upload/README.md | 232 ----- apps/web/app/auth/callback/page.tsx | 120 --- apps/web/app/page.tsx | 169 +-- apps/web/app/test-oidc/page.tsx | 248 ----- apps/web/components/login-button.tsx | 31 - apps/web/components/providers.tsx | 4 +- apps/web/components/user-profile.tsx | 251 ----- apps/web/lib/oidc-config.ts | 27 - apps/web/providers/auth-provider.tsx | 130 --- packages/oidc-provider/README.md | 158 --- .../examples/auto-key-generation.ts | 89 ++ .../oidc-provider/examples/auto-keypair.md | 91 ++ .../examples/test-auto-keypair.ts | 121 +++ .../examples/zod-validation-example.ts | 144 +++ packages/oidc-provider/package.json | 12 +- .../oidc-provider/src/errors/error-factory.ts | 109 -- packages/oidc-provider/src/errors/index.ts | 1 - packages/oidc-provider/src/provider.ts | 985 ++++++++---------- packages/oidc-provider/src/schemas/index.ts | 110 ++ packages/oidc-provider/src/types/index.ts | 7 +- packages/oidc-provider/src/utils/jwt.ts | 295 +++--- packages/openapi/src/index.ts | 3 - .../openapi/src/oidc/authorization.schema.ts | 40 - packages/openapi/src/oidc/client.schema.ts | 45 - packages/openapi/src/oidc/consent.schema.ts | 48 - packages/openapi/src/oidc/index.ts | 7 - packages/openapi/src/oidc/session.schema.ts | 19 - packages/openapi/src/oidc/token.schema.ts | 111 -- packages/openapi/src/oidc/userinfo.schema.ts | 28 - packages/openapi/src/user/index.ts | 1 - packages/openapi/src/user/user.schema.ts | 47 - test-oidc.ts | 1 - 38 files changed, 1294 insertions(+), 2676 deletions(-) delete mode 100644 apps/backend/src/oidc-demo.ts create mode 100644 apps/backend/src/oidc/auth.ts create mode 100644 apps/backend/src/oidc/clients.ts create mode 100644 apps/backend/src/oidc/index.ts create mode 100644 apps/backend/src/oidc/users.ts delete mode 100644 apps/backend/src/upload/README.md delete mode 100644 apps/web/app/auth/callback/page.tsx delete mode 100644 apps/web/app/test-oidc/page.tsx delete mode 100644 apps/web/components/login-button.tsx delete mode 100644 apps/web/components/user-profile.tsx delete mode 100644 apps/web/lib/oidc-config.ts delete mode 100644 apps/web/providers/auth-provider.tsx delete mode 100644 packages/oidc-provider/README.md create mode 100644 packages/oidc-provider/examples/auto-key-generation.ts create mode 100644 packages/oidc-provider/examples/auto-keypair.md create mode 100644 packages/oidc-provider/examples/test-auto-keypair.ts create mode 100644 packages/oidc-provider/examples/zod-validation-example.ts delete mode 100644 packages/oidc-provider/src/errors/error-factory.ts delete mode 100644 packages/oidc-provider/src/errors/index.ts create mode 100644 packages/oidc-provider/src/schemas/index.ts delete mode 100644 packages/openapi/src/oidc/authorization.schema.ts delete mode 100644 packages/openapi/src/oidc/client.schema.ts delete mode 100644 packages/openapi/src/oidc/consent.schema.ts delete mode 100644 packages/openapi/src/oidc/index.ts delete mode 100644 packages/openapi/src/oidc/session.schema.ts delete mode 100644 packages/openapi/src/oidc/token.schema.ts delete mode 100644 packages/openapi/src/oidc/userinfo.schema.ts delete mode 100644 packages/openapi/src/user/index.ts delete mode 100644 packages/openapi/src/user/user.schema.ts delete mode 100644 test-oidc.ts diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 4c70029..f5e82bf 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -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'; // 导入新的路由 @@ -20,6 +18,9 @@ import userRest from './user/user.rest'; import uploadRest from './upload/upload.rest'; import { startCleanupScheduler } from './upload/scheduler'; +// 导入OIDC Provider +import { oidcApp } from './oidc'; + type Env = { Variables: { redis: Redis; @@ -60,7 +61,8 @@ app.use( app.route('/api/users', userRest); app.route('/api/upload', uploadRest); - +// 挂载 OIDC Provider +app.route('/oidc', oidcApp); // 添加 WebSocket 路由 app.get('/ws', wsHandler); diff --git a/apps/backend/src/oidc-demo.ts b/apps/backend/src/oidc-demo.ts deleted file mode 100644 index 5c28e89..0000000 --- a/apps/backend/src/oidc-demo.ts +++ /dev/null @@ -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 { - return demoClients.find(client => client.client_id === clientId) || null; -} - -// 查找用户的函数 -async function findUser(userId: string): Promise { - return demoUsers.find(user => user.sub === userId) || null; -} - -// 密码验证函数 -async function validatePassword(username: string, password: string): Promise { - // 查找用户并验证密码 - 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 }; \ No newline at end of file diff --git a/apps/backend/src/oidc/auth.ts b/apps/backend/src/oidc/auth.ts new file mode 100644 index 0000000..9bbf270 --- /dev/null +++ b/apps/backend/src/oidc/auth.ts @@ -0,0 +1,11 @@ +import { users } from './users'; + +// 密码验证函数 +export async function validatePassword(username: string, password: string): Promise { + // 查找用户并验证密码 + const user = users.demoUsers.find(u => u.username === username); + if (!user || password !== 'demo123') { + return null; + } + return user.sub; // 返回用户ID +} \ No newline at end of file diff --git a/apps/backend/src/oidc/clients.ts b/apps/backend/src/oidc/clients.ts new file mode 100644 index 0000000..dd86ebc --- /dev/null +++ b/apps/backend/src/oidc/clients.ts @@ -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 { + return demoClients.find(client => client.client_id === clientId) || null; +} + +export const clients = { + findClient, + demoClients, +}; \ No newline at end of file diff --git a/apps/backend/src/oidc/index.ts b/apps/backend/src/oidc/index.ts new file mode 100644 index 0000000..ad0d6a7 --- /dev/null +++ b/apps/backend/src/oidc/index.ts @@ -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); \ No newline at end of file diff --git a/apps/backend/src/oidc/users.ts b/apps/backend/src/oidc/users.ts new file mode 100644 index 0000000..76c5011 --- /dev/null +++ b/apps/backend/src/oidc/users.ts @@ -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 { + return demoUsers.find(user => user.sub === userId) || null; +} + +export const users = { + findUser, + demoUsers, +}; \ No newline at end of file diff --git a/apps/backend/src/upload/README.md b/apps/backend/src/upload/README.md deleted file mode 100644 index 38aac07..0000000 --- a/apps/backend/src/upload/README.md +++ /dev/null @@ -1,232 +0,0 @@ -# 上传模块架构改造 - -本模块已从 NestJS 架构成功改造为 Hono + Bun 架构,并支持多种存储后端的无感切换。 - -## 文件结构 - -``` -src/upload/ -├── tus.ts # TUS 协议服务核心实现 -├── upload.index.ts # 资源管理相关函数 -├── upload.rest.ts # Hono REST API 路由 -├── storage.adapter.ts # 存储适配器系统 🆕 -├── storage.utils.ts # 存储工具类 🆕 -├── scheduler.ts # 定时清理任务 -├── utils.ts # 工具函数 -├── types.ts # 类型定义 -└── README.md # 本文档 -``` - -## 存储适配器系统 - -### 支持的存储类型 - -1. **本地存储 (Local)** - 文件存储在本地文件系统 -2. **S3 存储 (S3)** - 文件存储在 AWS S3 或兼容的对象存储服务 - -### 环境变量配置 - -#### 本地存储配置 - -```bash -STORAGE_TYPE=local -UPLOAD_DIR=./uploads -UPLOAD_EXPIRATION_MS=0 # 0 表示不自动过期(推荐设置) -``` - -#### S3 存储配置 - -```bash -STORAGE_TYPE=s3 -S3_BUCKET=your-bucket-name -S3_REGION=us-east-1 -S3_ACCESS_KEY_ID=your-access-key -S3_SECRET_ACCESS_KEY=your-secret-key -S3_ENDPOINT=https://s3.amazonaws.com # 可选,支持其他 S3 兼容服务 -S3_FORCE_PATH_STYLE=false # 可选,路径风格 -S3_PART_SIZE=8388608 # 可选,分片大小 (8MB) -S3_MAX_CONCURRENT_UPLOADS=60 # 可选,最大并发上传数 -UPLOAD_EXPIRATION_MS=0 # 0 表示不自动过期(推荐设置) -``` - -### 存储类型记录 - -- **数据库支持**: 每个资源记录都包含 `storageType` 字段,标识文件使用的存储后端 -- **自动记录**: 上传时自动记录当前的存储类型 -- **迁移支持**: 支持批量更新现有资源的存储类型标记 - -### 不过期设置 - -- **默认行为**: 过期时间默认设为 0,表示文件不会自动过期 -- **手动清理**: 提供多种手动清理选项 -- **灵活控制**: 可根据需要设置过期时间,或完全禁用自动清理 - -### 无感切换机制 - -1. **单例模式管理**: `StorageManager` 使用单例模式确保全局一致性 -2. **自动配置检测**: 启动时根据环境变量自动选择存储类型 -3. **统一接口**: 所有存储类型都实现相同的 TUS `DataStore` 接口 -4. **运行时切换**: 支持运行时切换存储配置(需要重启生效) - -## API 端点 - -### 资源管理 - -- `GET /api/upload/resource/:fileId` - 获取文件资源信息 -- `GET /api/upload/resources` - 获取所有资源 -- `GET /api/upload/resources/storage/:storageType` - 🆕 根据存储类型获取资源 -- `GET /api/upload/resources/status/:status` - 🆕 根据状态获取资源 -- `GET /api/upload/resources/uploading` - 🆕 获取正在上传的资源 -- `GET /api/upload/stats` - 🆕 获取资源统计信息 -- `DELETE /api/upload/resource/:id` - 删除资源 -- `PATCH /api/upload/resource/:id` - 更新资源 -- `POST /api/upload/cleanup` - 手动触发清理 -- `POST /api/upload/cleanup/by-status` - 🆕 根据状态清理资源 -- `POST /api/upload/migrate-storage` - 🆕 迁移资源存储类型标记 - -### 存储管理 - -- `GET /api/upload/storage/info` - 获取当前存储配置信息 -- `POST /api/upload/storage/switch` - 切换存储类型 -- `POST /api/upload/storage/validate` - 验证存储配置 - -### TUS 协议 - -- `OPTIONS /api/upload/*` - TUS 协议选项请求 -- `HEAD /api/upload/*` - TUS 协议头部请求 -- `POST /api/upload/*` - TUS 协议创建上传 -- `PATCH /api/upload/*` - TUS 协议上传数据 -- `GET /api/upload/*` - TUS 协议获取状态 - -## 新增 API 使用示例 - -### 获取存储类型统计 - -```javascript -const response = await fetch('/api/upload/stats'); -const stats = await response.json(); -// { -// total: 150, -// byStatus: { "UPLOADED": 120, "UPLOADING": 5, "PROCESSED": 25 }, -// byStorageType: { "local": 80, "s3": 70 } -// } -``` - -### 查询特定存储类型的资源 - -```javascript -const response = await fetch('/api/upload/resources/storage/s3'); -const s3Resources = await response.json(); -``` - -### 迁移存储类型标记 - -```javascript -const response = await fetch('/api/upload/migrate-storage', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ from: 'local', to: 's3' }), -}); -// { success: true, message: "Migrated 50 resources from local to s3", count: 50 } -``` - -### 手动清理特定状态的资源 - -```javascript -const response = await fetch('/api/upload/cleanup/by-status', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - status: 'UPLOADING', - olderThanDays: 7, - }), -}); -``` - -## 🆕 存储管理示例 - -### 获取存储信息 - -```javascript -const response = await fetch('/api/upload/storage/info'); -const storageInfo = await response.json(); -// { type: 'local', config: { directory: './uploads' } } -``` - -### 切换到 S3 存储 - -```javascript -const newConfig = { - type: 's3', - s3: { - bucket: 'my-bucket', - region: 'us-west-2', - accessKeyId: 'YOUR_ACCESS_KEY', - secretAccessKey: 'YOUR_SECRET_KEY', - }, -}; - -const response = await fetch('/api/upload/storage/switch', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(newConfig), -}); -``` - -### 验证存储配置 - -```javascript -const response = await fetch('/api/upload/storage/validate', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(newConfig), -}); -const validation = await response.json(); -// { valid: true, message: 'Storage configuration is valid' } -``` - -## 特性保留 - -1. **TUS 协议支持** - 完全保留原有的断点续传功能 -2. **文件命名** - 保留安全的文件命名策略 -3. **资源状态管理** - 保留完整的上传状态跟踪 -4. **自动清理** - 保留过期文件清理功能(默认禁用) -5. **数据库集成** - 保留 Prisma ORM 数据库操作 - -## 🆕 新增特性 - -1. **多存储后端支持** - 支持本地存储和 S3 存储 -2. **无感切换** - 运行时可切换存储类型 -3. **配置验证** - 提供存储配置验证功能 -4. **存储信息查询** - 可查询当前存储配置 -5. **统一日志** - 存储操作统一日志记录 -6. **🆕 存储类型记录** - 数据库记录每个资源的存储类型 -7. **🆕 灵活清理** - 支持按状态、时间等条件清理 -8. **🆕 统计分析** - 提供详细的资源统计信息 -9. **🆕 不过期设置** - 默认不自动过期,避免意外删除 - -## 运行 - -服务启动时会自动: - -1. 根据环境变量初始化存储适配器 -2. 初始化 TUS 服务器 -3. 注册 REST API 路由 -4. 启动定时清理任务(如果启用) - -支持的存储切换场景: - -- 开发环境使用本地存储 -- 生产环境使用 S3 存储 -- 混合云部署灵活切换 -- 存储迁移时批量更新资源标记 - -## 💡 最佳实践 - -1. **过期设置**: 推荐设置 `UPLOAD_EXPIRATION_MS=0` 避免文件意外过期 -2. **存储记录**: 利用数据库中的 `storageType` 字段追踪文件位置 -3. **定期清理**: 使用手动清理 API 定期清理不需要的资源 -4. **监控统计**: 使用统计 API 监控存储使用情况 -5. **迁移策略**: 在存储迁移时先更新环境变量,再使用迁移 API 更新数据库标记 - -无需代码修改,仅通过环境变量即可实现存储后端的无感切换。 diff --git a/apps/web/app/auth/callback/page.tsx b/apps/web/app/auth/callback/page.tsx deleted file mode 100644 index 2888e70..0000000 --- a/apps/web/app/auth/callback/page.tsx +++ /dev/null @@ -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(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 ; - case 'success': - return ; - case 'error': - return ; - } - }; - - const getStatusMessage = () => { - switch (status) { - case 'loading': - return '正在处理登录回调...'; - case 'success': - return '登录成功!正在跳转...'; - case 'error': - return '登录失败'; - } - }; - - return ( -
- - -
{getStatusIcon()}
- {getStatusMessage()} -
- - {status === 'error' && error && ( - - {error} - - )} - - {status === 'loading' && ( -
-

请等待,正在验证您的登录信息...

-
- )} - - {status === 'success' && ( -
-

登录成功!即将跳转到首页...

-
- )} - - {status === 'error' && ( -
- -
- )} -
-
-
- ); -} diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 41faba0..4947d39 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -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 ( -
-
- {/* 页面标题 */} -
-

OIDC 认证演示

-

- 基于 OpenID Connect 协议的安全认证系统演示 -

- -
- - {/* 功能特性卡片 */} -
- - - - 安全认证 - 基于 OAuth 2.0 和 OpenID Connect 标准的安全认证流程 - - - - - - - Token 管理 - 自动管理访问令牌、刷新令牌和 ID 令牌的生命周期 - - - - - - - 用户信息 - 获取和展示完整的用户配置文件信息 - - -
- - {/* 状态显示 */} - {error && ( - - -
- - 错误: - {error} -
-
-
- )} - - {/* 主要内容区域 */} -
- {isLoading ? ( - - -
-
-
-
-
-
-
- ) : isAuthenticated ? ( - - ) : ( - - - 欢迎使用 - 请点击下方按钮开始 OIDC 认证流程 - - - - - - -
-

演示账户信息:

-
-

- 用户名: demouser -

-

- 密码: demo123 -

-
-
-
-
- )} - - {/* 技术信息 */} - - - - - 技术实现特性 - - - -
-
-

前端技术栈

-
- Next.js 15 - React 19 - TypeScript - oidc-client-ts - Tailwind CSS -
-
- -
-

后端技术栈

-
- Hono - OIDC Provider - Redis - JWT - PKCE -
-
- -
-

安全特性

-
- 授权码流程 - PKCE 支持 - Token 轮换 - 安全存储 -
-
- -
-

支持的作用域

-
- openid - profile - email - phone - address -
-
-
-
-
-
-
-
- ); + return <>; } diff --git a/apps/web/app/test-oidc/page.tsx b/apps/web/app/test-oidc/page.tsx deleted file mode 100644 index c680bb0..0000000 --- a/apps/web/app/test-oidc/page.tsx +++ /dev/null @@ -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 ; - case 'success': - return ; - case 'error': - return ; - default: - return
; - } - }; - - return ( -
-
- {/* 页面标题 */} -
-

OIDC 流程测试

-

测试和验证 OpenID Connect 认证流程的各个环节

-
- - {/* OIDC 流程步骤 */} - - - - - 标准 OIDC 认证流程 - - 按照正确的 OIDC 架构,所有登录都在 OIDC Provider 中处理 - - - {/* 流程步骤图示 */} -
-
-
- -
-

用户点击登录

-
- -
- -
- -
-
- -
-

重定向到 Provider

-
- -
- -
- -
-
- -
-

返回授权码

-
-
- - {/* 测试按钮 */} -
- -
- - {/* 提示信息 */} - - - - 点击上方按钮将重定向到 OIDC Provider 的登录页面。 -
- 演示账号: demouser / demo123 -
-
-
-
- - {/* Discovery 端点测试 */} - - - - {getStatusIcon(testResults.discovery)} - Discovery 端点测试 - - 测试 OIDC Provider 的配置发现端点 - - - - - {testResults.discovery === 'error' && ( - - - - 错误: {testResults.discoveryError} - - - )} - - {testResults.discovery === 'success' && testResults.discoveryData && ( -
- - - - 成功! OIDC Provider 配置已获取 - - - -
-

Provider 信息

-
-

- Issuer:{' '} - - {testResults.discoveryData.issuer} - -

-

- 授权端点:{' '} - - {testResults.discoveryData.authorization_endpoint} - -

-

- 令牌端点:{' '} - - {testResults.discoveryData.token_endpoint} - -

-
-
- -
-

支持的功能

-
- {testResults.discoveryData.response_types_supported?.map((type: string) => ( - - {type} - - ))} -
-
- {testResults.discoveryData.scopes_supported?.map((scope: string) => ( - - {scope} - - ))} -
-
-
- )} -
-
- - {/* 架构说明 */} - - - 正确的 OIDC 架构 - - -
-
-

✅ 已实现

-
    -
  • • OIDC Provider 包含登录页面
  • -
  • • 标准授权码流程
  • -
  • • PKCE 支持
  • -
  • • 内置会话管理
  • -
  • • 自动令牌刷新
  • -
-
-
-

❌ 已移除

-
    -
  • • 客户端应用的登录页面
  • -
  • • 自定义认证逻辑
  • -
  • • 重复的用户管理
  • -
  • • 混合认证流程
  • -
-
-
-
-
-
-
- ); -} diff --git a/apps/web/components/login-button.tsx b/apps/web/components/login-button.tsx deleted file mode 100644 index 08e9490..0000000 --- a/apps/web/components/login-button.tsx +++ /dev/null @@ -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 ( - - ); -} diff --git a/apps/web/components/providers.tsx b/apps/web/components/providers.tsx index 8832538..553aede 100644 --- a/apps/web/components/providers.tsx +++ b/apps/web/components/providers.tsx @@ -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 ( - {children} + {children} ); diff --git a/apps/web/components/user-profile.tsx b/apps/web/components/user-profile.tsx deleted file mode 100644 index d638dc6..0000000 --- a/apps/web/components/user-profile.tsx +++ /dev/null @@ -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 ( - - -
-
-
-
-
-
-
- ); - } - - if (!isAuthenticated || !user) { - return ( - - - 未登录 - 请先登录以查看用户信息 - - - ); - } - - const profile = user.profile; - const formatDate = (timestamp?: number) => { - if (!timestamp) return '未知'; - return new Date(timestamp * 1000).toLocaleString('zh-CN'); - }; - - return ( - - -
-
- - - {profile.name?.charAt(0) || profile.preferred_username?.charAt(0) || 'U'} - -
- {profile.name || profile.preferred_username || '未知用户'} - 用户ID: {profile.sub} -
-
- -
-
- - - {/* 基本信息 */} -
-

- - 基本信息 -

-
- {profile.given_name && ( -
- -

{profile.given_name}

-
- )} - {profile.family_name && ( -
- -

{profile.family_name}

-
- )} - {profile.nickname && ( -
- -

{profile.nickname}

-
- )} - {profile.gender && ( -
- -

{profile.gender}

-
- )} -
-
- - - - {/* 联系信息 */} -
-

- - 联系信息 -

-
- {profile.email && ( -
-
- -

{profile.email}

-
- - {profile.email_verified ? '已验证' : '未验证'} - -
- )} - {profile.phone_number && ( -
-
- -

{profile.phone_number}

-
- - {profile.phone_number_verified ? '已验证' : '未验证'} - -
- )} -
-
- - {profile.address && ( - <> - -
-

- - 地址信息 -

-
- {profile.address.formatted && ( -
- -

{profile.address.formatted}

-
- )} -
- {profile.address.country && ( -
- -

{profile.address.country}

-
- )} - {profile.address.region && ( -
- -

{profile.address.region}

-
- )} - {profile.address.locality && ( -
- -

{profile.address.locality}

-
- )} - {profile.address.postal_code && ( -
- -

{profile.address.postal_code}

-
- )} -
-
-
- - )} - - - - {/* 其他信息 */} -
-

- - 其他信息 -

-
- {profile.birthdate && ( -
- -

{profile.birthdate}

-
- )} - {profile.zoneinfo && ( -
- -

{profile.zoneinfo}

-
- )} - {profile.locale && ( -
- -

{profile.locale}

-
- )} - {profile.updated_at && ( -
- -

{formatDate(profile.updated_at)}

-
- )} -
-
- - - - {/* Token 信息 */} -
-

- - Token 信息 -

-
- {user.expires_at && ( -
- -

{new Date(user.expires_at * 1000).toLocaleString('zh-CN')}

-
- )} -
- -

{user.token_type}

-
-
- -
- {user.scope?.split(' ').map((scope) => ( - - {scope} - - ))} -
-
-
-
-
-
- ); -} diff --git a/apps/web/lib/oidc-config.ts b/apps/web/lib/oidc-config.ts deleted file mode 100644 index 09ba761..0000000 --- a/apps/web/lib/oidc-config.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { UserManager, WebStorageStateStore } from 'oidc-client-ts'; - -// 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, - userStore: new WebStorageStateStore({ store: window?.localStorage }), -}; - -// 创建用户管理器实例 -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`, -}; \ No newline at end of file diff --git a/apps/web/providers/auth-provider.tsx b/apps/web/providers/auth-provider.tsx deleted file mode 100644 index 55d5326..0000000 --- a/apps/web/providers/auth-provider.tsx +++ /dev/null @@ -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; - logout: () => Promise; - error: string | null; -} - -const AuthContext = createContext(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 = ({ children }) => { - const [user, setUser] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(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 {children}; -}; diff --git a/packages/oidc-provider/README.md b/packages/oidc-provider/README.md deleted file mode 100644 index 611f14d..0000000 --- a/packages/oidc-provider/README.md +++ /dev/null @@ -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 \ No newline at end of file diff --git a/packages/oidc-provider/examples/auto-key-generation.ts b/packages/oidc-provider/examples/auto-key-generation.ts new file mode 100644 index 0000000..04970ad --- /dev/null +++ b/packages/oidc-provider/examples/auto-key-generation.ts @@ -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 }; \ No newline at end of file diff --git a/packages/oidc-provider/examples/auto-keypair.md b/packages/oidc-provider/examples/auto-keypair.md new file mode 100644 index 0000000..e22f718 --- /dev/null +++ b/packages/oidc-provider/examples/auto-keypair.md @@ -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', + // ... 其他配置 +}); +``` \ No newline at end of file diff --git a/packages/oidc-provider/examples/test-auto-keypair.ts b/packages/oidc-provider/examples/test-auto-keypair.ts new file mode 100644 index 0000000..5dc815d --- /dev/null +++ b/packages/oidc-provider/examples/test-auto-keypair.ts @@ -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 { + console.log(`存储: ${key}`); + }, + async get(key: string): Promise { + return null; + }, + async delete(key: string): Promise { + console.log(`删除: ${key}`); + } +}; + +// 基础配置 +const baseConfig: Omit = { + 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); \ No newline at end of file diff --git a/packages/oidc-provider/examples/zod-validation-example.ts b/packages/oidc-provider/examples/zod-validation-example.ts new file mode 100644 index 0000000..18276b9 --- /dev/null +++ b/packages/oidc-provider/examples/zod-validation-example.ts @@ -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 => { + const users: Record = { + '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 => { + const clients: Record = { + '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 => { + // 这里应该实现真实的密码验证逻辑 + 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) => { + 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); + } + } +}; \ No newline at end of file diff --git a/packages/oidc-provider/package.json b/packages/oidc-provider/package.json index 542fc97..987ccda 100644 --- a/packages/oidc-provider/package.json +++ b/packages/oidc-provider/package.json @@ -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" diff --git a/packages/oidc-provider/src/errors/error-factory.ts b/packages/oidc-provider/src/errors/error-factory.ts deleted file mode 100644 index de1ce65..0000000 --- a/packages/oidc-provider/src/errors/error-factory.ts +++ /dev/null @@ -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; - } -} \ No newline at end of file diff --git a/packages/oidc-provider/src/errors/index.ts b/packages/oidc-provider/src/errors/index.ts deleted file mode 100644 index 4a596c9..0000000 --- a/packages/oidc-provider/src/errors/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { OIDCErrorFactory } from './error-factory'; \ No newline at end of file diff --git a/packages/oidc-provider/src/provider.ts b/packages/oidc-provider/src/provider.ts index f0a81ab..f91bf0f 100644 --- a/packages/oidc-provider/src/provider.ts +++ b/packages/oidc-provider/src/provider.ts @@ -1,47 +1,41 @@ import { nanoid } from 'nanoid'; import type { Context } from 'hono'; +import { z } from 'zod'; +import { + authorizationRequestSchema, + tokenRequestSchema, + authorizationQuerySchema, + tokenFormDataSchema, + bearerTokenSchema, + basicAuthSchema, + revokeTokenRequestSchema, + introspectTokenRequestSchema, +} from './schemas'; import type { OIDCProviderConfig, OIDCClient, OIDCUser, AuthorizationCode, - AccessToken, - RefreshToken, - IDToken, AuthorizationRequest, TokenRequest, TokenResponse, OIDCError, DiscoveryDocument, - PasswordValidator, } from './types'; import type { StorageAdapter } from './storage/adapter'; import { TokenManager } from './auth/token-manager'; import { JWTUtils } from './utils/jwt'; +import type { KeyPair } from './utils/jwt'; import { PKCEUtils } from './utils/pkce'; import { ValidationUtils } from './utils/validation'; import { PasswordAuth } from './auth'; -import { OIDCErrorFactory } from './errors'; /** - * OIDC Provider核心类 + * OIDC Provider - 简化版本,符合规范且代码简洁 */ export class OIDCProvider { - private readonly config: OIDCProviderConfig & { - tokenTTL: { - accessToken: number; - refreshToken: number; - authorizationCode: number; - idToken: number; - }; - responseTypes: string[]; - grantTypes: string[]; - scopes: string[]; - claims: string[]; - enablePKCE: boolean; - requirePKCE: boolean; - rotateRefreshTokens: boolean; - }; + private readonly config: OIDCProviderConfig; + private readonly tokenTTL: Required>; private readonly storage: StorageAdapter; private readonly tokenManager: TokenManager; private readonly jwtUtils: JWTUtils; @@ -50,676 +44,619 @@ export class OIDCProvider { private passwordAuth: PasswordAuth; constructor(config: OIDCProviderConfig) { - this.config = this.normalizeConfig(config); + this.validateConfig(config); + this.config = config; + this.tokenTTL = { + accessToken: config.tokenTTL?.accessToken || 3600, + refreshToken: config.tokenTTL?.refreshToken || 2592000, + authorizationCode: config.tokenTTL?.authorizationCode || 600, + idToken: config.tokenTTL?.idToken || 3600, + }; + this.storage = config.storage; this.tokenManager = new TokenManager(config.storage); this.findUser = config.findUser; this.findClient = config.findClient; - this.jwtUtils = new JWTUtils(config.signingKey, config.signingAlgorithm); - this.passwordAuth = new PasswordAuth(this.config, config.authConfig.passwordValidator, config.authConfig); + + // 创建JWT工具类,使用简化的构造函数 + this.jwtUtils = new JWTUtils(config.signingKey); + + this.passwordAuth = new PasswordAuth(config, config.authConfig.passwordValidator, config.authConfig); } - private normalizeConfig(config: OIDCProviderConfig) { - return { - ...config, - tokenTTL: { - accessToken: 3600, - refreshToken: 30 * 24 * 3600, - authorizationCode: 600, - idToken: 3600, - ...(config.tokenTTL || {}), - }, - responseTypes: config.responseTypes || ['code', 'id_token', 'token', 'code id_token', 'code token', 'id_token token', 'code id_token token'], - grantTypes: config.grantTypes || ['authorization_code', 'refresh_token', 'implicit'], - scopes: config.scopes || ['openid', 'profile', 'email', 'phone', 'address'], - claims: config.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: config.enablePKCE ?? true, - requirePKCE: config.requirePKCE ?? false, - rotateRefreshTokens: config.rotateRefreshTokens ?? true, - }; + private validateConfig(config: OIDCProviderConfig): void { + const required = ['issuer', 'storage', 'findUser', 'findClient', 'authConfig']; + for (const field of required) { + if (!config[field as keyof OIDCProviderConfig]) { + throw new Error(`配置项 ${field} 是必需的`); + } + } + + // 如果提供了signingKey且是字符串,说明使用HMAC + // 如果没有提供signingKey,将自动生成RSA密钥 } - // 统一的令牌查找和验证 - private async findTokenData(token: string): Promise<{ - accessToken?: AccessToken | null; - refreshToken?: RefreshToken | null; - tokenData?: AccessToken | RefreshToken | null; - isExpired: boolean; - }> { + private get defaultScopes(): string[] { + return this.config.scopes || ['openid', 'profile', 'email']; + } + + private get supportedResponseTypes(): string[] { + return this.config.responseTypes || ['code']; + } + + private get supportedGrantTypes(): string[] { + return this.config.grantTypes || ['authorization_code', 'refresh_token']; + } + + private get supportedClaims(): string[] { + return this.config.claims || ['sub', 'name', 'email', 'email_verified']; + } + + private get enablePKCE(): boolean { + return this.config.enablePKCE ?? true; + } + + private get requirePKCE(): boolean { + return this.config.requirePKCE ?? false; + } + + private get rotateRefreshTokens(): boolean { + return this.config.rotateRefreshTokens ?? true; + } + + async findToken(token: string): Promise<{ tokenData: any; type: 'access' | 'refresh' } | null> { const [accessToken, refreshToken] = await Promise.all([ this.tokenManager.getAccessToken(token), this.tokenManager.getRefreshToken(token) ]); const tokenData = accessToken || refreshToken; - const isExpired = tokenData ? tokenData.expires_at < new Date() : false; - - if (isExpired && tokenData) { + if (tokenData && tokenData.expires_at < new Date()) { + // 清理过期token await Promise.all([ - accessToken ? this.tokenManager.deleteAccessToken(token) : Promise.resolve(), - refreshToken ? this.tokenManager.deleteRefreshToken(token) : Promise.resolve(), - ]); + accessToken && this.tokenManager.deleteAccessToken(token), + refreshToken && this.tokenManager.deleteRefreshToken(token), + ].filter(Boolean)); + return null; } - return { accessToken, refreshToken, tokenData, isExpired }; + if (!tokenData) return null; + return { tokenData, type: accessToken ? 'access' : 'refresh' }; } - private parseAuthorizationRequest(query: Record): AuthorizationRequest { - return { - response_type: query.response_type || '', - client_id: query.client_id || '', - redirect_uri: query.redirect_uri || '', - scope: query.scope || '', - state: query.state, - nonce: query.nonce, - code_challenge: query.code_challenge, - code_challenge_method: query.code_challenge_method as 'plain' | 'S256' | undefined, - prompt: query.prompt, - max_age: query.max_age ? parseInt(query.max_age, 10) : undefined, - id_token_hint: query.id_token_hint, - login_hint: query.login_hint, - acr_values: query.acr_values, - }; + parseAuthRequest(query: Record): AuthorizationRequest { + const normalized = authorizationQuerySchema.parse(query); + return authorizationRequestSchema.parse({ + response_type: normalized.response_type || '', + client_id: normalized.client_id || '', + redirect_uri: normalized.redirect_uri || '', + scope: normalized.scope || 'openid', + state: normalized.state, + nonce: normalized.nonce, + code_challenge: normalized.code_challenge, + code_challenge_method: normalized.code_challenge_method as 'plain' | 'S256' | undefined, + prompt: normalized.prompt, + max_age: normalized.max_age ? parseInt(normalized.max_age, 10) : undefined, + id_token_hint: normalized.id_token_hint, + login_hint: normalized.login_hint, + acr_values: normalized.acr_values, + }); } - private parseTokenRequest(body: FormData, authHeader?: string): TokenRequest { - const tokenRequest: any = { - grant_type: body.get('grant_type')?.toString() || '', - client_id: body.get('client_id')?.toString() || '' - }; + parseTokenRequest(body: FormData, authHeader?: string): TokenRequest { + const formData = tokenFormDataSchema.parse(body); - ['code', 'redirect_uri', 'client_secret', 'refresh_token', 'code_verifier', 'scope'] - .forEach(field => { - const value = body.get(field)?.toString(); - if (value) tokenRequest[field] = value; - }); - - // 处理Basic认证 + // 处理客户端认证 if (authHeader?.startsWith('Basic ')) { - try { - const [id, secret] = atob(authHeader.substring(6)).split(':'); - if (id) tokenRequest.client_id = id; - if (secret) tokenRequest.client_secret = secret; - } catch { - // 忽略无效的Basic认证头 + const basicAuth = basicAuthSchema.parse(authHeader); + formData.client_id = basicAuth.username || formData.client_id || ''; + formData.client_secret = basicAuth.password || formData.client_secret || ''; + } + + return tokenRequestSchema.parse({ + grant_type: formData.grant_type || '', + client_id: formData.client_id || '', + code: formData.code, + redirect_uri: formData.redirect_uri, + client_secret: formData.client_secret, + refresh_token: formData.refresh_token, + code_verifier: formData.code_verifier, + scope: formData.scope, + }); + } + + async validateClient(clientId: string, clientSecret?: string): Promise { + const client = await this.findClient(clientId); + if (!client) throw new Error('客户端不存在'); + + // 机密客户端需要密钥 + if (client.client_type === 'confidential' && clientSecret !== client.client_secret) { + throw new Error('客户端认证失败'); + } + + // 公开客户端不应该发送密钥 + if (client.client_type === 'public' && clientSecret) { + throw new Error('公开客户端不应发送密钥'); + } + + return client; + } + + validatePKCE(request: AuthorizationRequest, client: OIDCClient): void { + const isPublic = client.client_type === 'public'; + const hasChallenge = !!request.code_challenge; + + if (this.requirePKCE && isPublic && !hasChallenge) { + throw new Error('公开客户端必须使用PKCE'); + } + + if (hasChallenge && request.code_challenge) { + const codeChallenge = request.code_challenge; + const method = request.code_challenge_method || 'plain'; + + if (!PKCEUtils.isValidCodeChallenge(codeChallenge, method)) { + throw new Error('无效的PKCE参数'); } } - - return tokenRequest; - } - - configurePasswordAuth(passwordValidator: PasswordValidator, authConfig: OIDCProviderConfig['authConfig']): void { - this.passwordAuth = new PasswordAuth(this.config, passwordValidator, authConfig); } getDiscoveryDocument(): DiscoveryDocument { const baseUrl = this.config.issuer; + // 根据signingKey类型确定签名算法 + const signingAlgorithm = typeof this.config.signingKey === 'string' ? 'HS256' : 'RS256'; + return { issuer: this.config.issuer, authorization_endpoint: `${baseUrl}/auth`, token_endpoint: `${baseUrl}/token`, userinfo_endpoint: `${baseUrl}/userinfo`, jwks_uri: `${baseUrl}/.well-known/jwks.json`, - registration_endpoint: `${baseUrl}/register`, revocation_endpoint: `${baseUrl}/revoke`, introspection_endpoint: `${baseUrl}/introspect`, end_session_endpoint: `${baseUrl}/logout`, - response_types_supported: this.config.responseTypes, - grant_types_supported: this.config.grantTypes, - scopes_supported: this.config.scopes, - claims_supported: this.config.claims, + response_types_supported: this.supportedResponseTypes, + grant_types_supported: this.supportedGrantTypes, + scopes_supported: this.defaultScopes, + claims_supported: this.supportedClaims, token_endpoint_auth_methods_supported: ['client_secret_basic', 'client_secret_post', 'none'], - id_token_signing_alg_values_supported: ['HS256', 'RS256', 'ES256'], + id_token_signing_alg_values_supported: [signingAlgorithm], subject_types_supported: ['public'], - code_challenge_methods_supported: this.config.enablePKCE ? ['plain', 'S256'] : undefined, - response_modes_supported: ['query', 'fragment', 'form_post'], - claims_parameter_supported: false, - request_parameter_supported: false, - request_uri_parameter_supported: false, - require_request_uri_registration: false, + code_challenge_methods_supported: this.enablePKCE ? ['plain', 'S256'] : undefined, + response_modes_supported: ['query', 'fragment'], }; } - async handleAuthorizationRequest( - request: Partial, - userId?: string - ): Promise<{ success: true; code: string; redirectUri: string } | { success: false; error: OIDCError; redirectUri?: string }> { - try { - const validationResult = await this.validateAuthorizationRequest(request, userId); - if (!validationResult.success) return validationResult; + async handleAuthorizationRequest(request: AuthorizationRequest, userId?: string) { + // 基本验证 + if (!request.client_id || !request.response_type || !request.redirect_uri) { + throw new Error('缺少必需参数'); + } + const client = await this.findClient(request.client_id); + if (!client || !client.redirect_uris.includes(request.redirect_uri)) { + throw new Error('无效的客户端或重定向URI'); + } + + // 验证请求 + const validation = ValidationUtils.validateAuthorizationRequest( + request, client, this.defaultScopes, this.supportedResponseTypes + ); + if (!validation.valid) { + throw new Error(validation.errors.join(', ')); + } + + if (!userId) { + throw new Error('需要用户认证'); + } + + this.validatePKCE(request, client); + + // 生成授权码 + if (request.response_type === 'code') { const code = nanoid(32); - const now = new Date(); - const authCode: AuthorizationCode = { code, - client_id: request.client_id!, - user_id: userId!, - redirect_uri: request.redirect_uri!, - scope: request.scope!, + client_id: request.client_id, + user_id: userId, + redirect_uri: request.redirect_uri, + scope: request.scope, code_challenge: request.code_challenge, code_challenge_method: request.code_challenge_method, nonce: request.nonce, state: request.state, - expires_at: new Date(now.getTime() + this.config.tokenTTL.authorizationCode * 1000), - created_at: now, + expires_at: new Date(Date.now() + this.tokenTTL.authorizationCode * 1000), + created_at: new Date(), }; await this.tokenManager.storeAuthorizationCode(authCode); - return { success: true, code, redirectUri: request.redirect_uri! }; - } catch (error) { - return { ...OIDCErrorFactory.serverError(request.state), redirectUri: request.redirect_uri }; + return { code, state: request.state }; + } + + throw new Error(`不支持的响应类型: ${request.response_type}`); + } + + async handleTokenRequest(request: TokenRequest): Promise { + if (!request.grant_type || !request.client_id) { + throw new Error('缺少必需参数'); + } + + const client = await this.validateClient(request.client_id, request.client_secret); + + switch (request.grant_type) { + case 'authorization_code': + return this.handleAuthorizationCodeGrant(request, client); + case 'refresh_token': + return this.handleRefreshTokenGrant(request, client); + default: + throw new Error(`不支持的授权类型: ${request.grant_type}`); } } - private async validateAuthorizationRequest( - request: Partial, - userId?: string - ): Promise<{ success: true; client: OIDCClient } | { success: false; error: OIDCError; redirectUri?: string }> { - if (!request.client_id) { - return { - success: false, - error: { error: 'invalid_request', error_description: 'Missing client_id parameter', state: request.state }, - redirectUri: request.redirect_uri, - }; + private async handleAuthorizationCodeGrant(request: TokenRequest, client: OIDCClient): Promise { + if (!request.code || !request.redirect_uri) { + throw new Error('缺少授权码或重定向URI'); } - const client = await this.findClient(request.client_id); - if (!client) { - return { - success: false, - error: { error: 'invalid_client', error_description: 'Invalid client_id', state: request.state }, - redirectUri: request.redirect_uri, - }; - } - - const validation = ValidationUtils.validateAuthorizationRequest( - request, client, this.config.scopes, this.config.responseTypes - ); - if (!validation.valid) { - return { - success: false, - error: { error: 'invalid_request', error_description: validation.errors.join(', '), state: request.state }, - redirectUri: request.redirect_uri, - }; - } - - if (!userId) { - return { - success: false, - error: { error: 'login_required', error_description: 'User authentication is required', state: request.state }, - redirectUri: request.redirect_uri, - }; - } - - const pkceError = this.validatePKCE(request, client); - if (pkceError) return { success: false, error: pkceError, redirectUri: request.redirect_uri }; - - return { success: true, client }; - } - - private validatePKCE(request: Partial, client: OIDCClient): OIDCError | null { - if (this.config.requirePKCE && client.client_type === 'public' && !request.code_challenge) { - return { error: 'invalid_request', error_description: 'PKCE is required for public clients', state: request.state }; - } - - if (this.config.enablePKCE && request.code_challenge) { - if (!request.code_challenge_method) { - return { error: 'invalid_request', error_description: 'Missing code_challenge_method', state: request.state }; - } - if (!PKCEUtils.isSupportedMethod(request.code_challenge_method)) { - return { error: 'invalid_request', error_description: 'Unsupported code_challenge_method', state: request.state }; - } - if (!PKCEUtils.isValidCodeChallenge(request.code_challenge, request.code_challenge_method)) { - return { error: 'invalid_request', error_description: 'Invalid code_challenge', state: request.state }; - } - } - return null; - } - - async handleTokenRequest( - request: Partial - ): Promise<{ success: true; response: TokenResponse } | { success: false; error: OIDCError }> { - try { - const validationResult = await this.validateTokenRequest(request); - if (!validationResult.success) return validationResult; - - const { client } = validationResult; - - if (request.grant_type === 'authorization_code') { - return await this.handleAuthorizationCodeGrant(request, client); - } else if (request.grant_type === 'refresh_token') { - return await this.handleRefreshTokenGrant(request, client); - } else { - return OIDCErrorFactory.unsupportedGrantType(); - } - } catch (error) { - return OIDCErrorFactory.createTokenError('server_error', 'Internal server error'); - } - } - - private async validateTokenRequest( - request: Partial - ): Promise<{ success: true; client: OIDCClient } | { success: false; error: OIDCError }> { - if (!request.client_id) return OIDCErrorFactory.createTokenError('invalid_request', 'Missing client_id parameter'); - - const client = await this.findClient(request.client_id); - if (!client) return OIDCErrorFactory.invalidClient('Invalid client_id'); - - // 客户端认证 - if (client.client_type === 'confidential') { - if (!request.client_secret) return OIDCErrorFactory.invalidClient('Client authentication required'); - if (request.client_secret !== client.client_secret) return OIDCErrorFactory.invalidClient('Invalid client credentials'); - } - - const validation = ValidationUtils.validateTokenRequest(request, client, this.config.grantTypes); - if (!validation.valid) return OIDCErrorFactory.createTokenError('invalid_request', validation.errors.join(', ')); - - return { success: true, client }; - } - - private async handleAuthorizationCodeGrant( - request: Partial, - client: OIDCClient - ): Promise<{ success: true; response: TokenResponse } | { success: false; error: OIDCError }> { - if (!request.code) return OIDCErrorFactory.createTokenError('invalid_request', 'Missing authorization code'); - const authCode = await this.tokenManager.getAuthorizationCode(request.code); - if (!authCode) return OIDCErrorFactory.invalidGrant('Invalid authorization code'); - - if (authCode.expires_at < new Date()) { - await this.tokenManager.deleteAuthorizationCode(request.code); - return OIDCErrorFactory.invalidGrant('Authorization code expired'); + if (!authCode || authCode.expires_at < new Date()) { + if (authCode) await this.tokenManager.deleteAuthorizationCode(request.code); + throw new Error('授权码无效或已过期'); } - if (authCode.client_id !== request.client_id) return OIDCErrorFactory.invalidGrant('Authorization code was not issued to this client'); - if (authCode.redirect_uri !== request.redirect_uri) return OIDCErrorFactory.invalidGrant('Redirect URI mismatch'); + // 验证授权码 + if (authCode.client_id !== request.client_id || authCode.redirect_uri !== request.redirect_uri) { + throw new Error('授权码不匹配'); + } - // PKCE验证 + // 验证PKCE if (authCode.code_challenge) { - if (!request.code_verifier) return OIDCErrorFactory.createTokenError('invalid_request', 'Missing code_verifier'); - if (!PKCEUtils.verifyCodeChallenge(request.code_verifier, authCode.code_challenge, authCode.code_challenge_method || 'plain')) { - return OIDCErrorFactory.invalidGrant('Invalid code_verifier'); + if (!request.code_verifier || !PKCEUtils.verifyCodeChallenge( + request.code_verifier, authCode.code_challenge, authCode.code_challenge_method || 'plain' + )) { + throw new Error('PKCE验证失败'); } } const user = await this.findUser(authCode.user_id); - if (!user) return OIDCErrorFactory.invalidGrant('User not found'); + if (!user) throw new Error('用户不存在'); + // 删除已使用的授权码 + await this.tokenManager.deleteAuthorizationCode(request.code); + + return this.generateTokens(user, client, authCode.scope, authCode.nonce); + } + + private async handleRefreshTokenGrant(request: TokenRequest, client: OIDCClient): Promise { + if (!request.refresh_token) { + throw new Error('缺少刷新token'); + } + + const refreshToken = await this.tokenManager.getRefreshToken(request.refresh_token); + if (!refreshToken || refreshToken.expires_at < new Date()) { + if (refreshToken) await this.tokenManager.deleteRefreshToken(request.refresh_token); + throw new Error('刷新token无效或已过期'); + } + + if (refreshToken.client_id !== request.client_id) { + throw new Error('刷新token客户端不匹配'); + } + + const user = await this.findUser(refreshToken.user_id); + if (!user) throw new Error('用户不存在'); + + // 验证scope + let scope = refreshToken.scope; + if (request.scope) { + const requestedScopes = request.scope.split(' '); + const originalScopes = refreshToken.scope.split(' '); + if (!requestedScopes.every(s => originalScopes.includes(s))) { + throw new Error('请求的scope超出原始scope'); + } + scope = request.scope; + } + + const tokens = await this.generateTokens(user, client, scope); + + // 旋转刷新token + if (this.rotateRefreshTokens) { + await this.tokenManager.deleteRefreshToken(request.refresh_token); + } else { + tokens.refresh_token = request.refresh_token; + } + + return tokens; + } + + private async generateTokens( + user: OIDCUser, + client: OIDCClient, + scope: string, + nonce?: string + ): Promise { const now = new Date(); - const accessTokenJWT = await this.jwtUtils.generateAccessToken({ - issuer: this.config.issuer, - subject: user.sub, - audience: client.client_id, - clientId: client.client_id, - scope: authCode.scope, - expiresIn: this.config.tokenTTL.accessToken, - }); + const [accessToken, refreshTokenValue] = await Promise.all([ + this.jwtUtils.generateAccessToken({ + issuer: this.config.issuer, + subject: user.sub, + audience: client.client_id, + clientId: client.client_id, + scope, + expiresIn: this.tokenTTL.accessToken, + }), + nanoid(64) + ]); - const refreshTokenValue = nanoid(64); - - let idTokenJWT: string | undefined; - if (authCode.scope.includes('openid')) { - const requestedClaims = this.getRequestedClaims(authCode.scope); - idTokenJWT = await this.jwtUtils.generateIDToken({ + // 生成ID Token(如果scope包含openid) + let idToken: string | undefined; + if (scope.includes('openid')) { + idToken = await this.jwtUtils.generateIDToken({ issuer: this.config.issuer, subject: user.sub, audience: client.client_id, user, authTime: Math.floor(now.getTime() / 1000), - nonce: authCode.nonce, - expiresIn: this.config.tokenTTL.idToken, - requestedClaims, + nonce, + expiresIn: this.tokenTTL.idToken, }); } - // 存储令牌 - const accessTokenExpiry = new Date(now.getTime() + this.config.tokenTTL.accessToken * 1000); - const refreshTokenExpiry = new Date(now.getTime() + this.config.tokenTTL.refreshToken * 1000); - const idTokenExpiry = new Date(now.getTime() + this.config.tokenTTL.idToken * 1000); - - await Promise.all([ + // 存储tokens + const storeOperations = [ this.tokenManager.storeAccessToken({ - token: accessTokenJWT, + token: accessToken, client_id: client.client_id, user_id: user.sub, - scope: authCode.scope, - expires_at: accessTokenExpiry, + scope, + expires_at: new Date(now.getTime() + this.tokenTTL.accessToken * 1000), created_at: now, }), this.tokenManager.storeRefreshToken({ token: refreshTokenValue, client_id: client.client_id, user_id: user.sub, - scope: authCode.scope, - expires_at: refreshTokenExpiry, + scope, + expires_at: new Date(now.getTime() + this.tokenTTL.refreshToken * 1000), created_at: now, - }), - ...(idTokenJWT ? [this.tokenManager.storeIDToken({ - token: idTokenJWT, + }) + ]; + + if (idToken) { + storeOperations.push(this.tokenManager.storeIDToken({ + token: idToken, client_id: client.client_id, user_id: user.sub, - nonce: undefined, - expires_at: idTokenExpiry, + nonce, + expires_at: new Date(now.getTime() + this.tokenTTL.idToken * 1000), created_at: now, - })] : []) - ]); + })); + } - await this.tokenManager.deleteAuthorizationCode(request.code); + await Promise.all(storeOperations); const response: TokenResponse = { - access_token: accessTokenJWT, + access_token: accessToken, token_type: 'Bearer', - expires_in: this.config.tokenTTL.accessToken, + expires_in: this.tokenTTL.accessToken, refresh_token: refreshTokenValue, - scope: authCode.scope, - }; - - if (idTokenJWT) response.id_token = idTokenJWT; - return { success: true, response }; - } - - private async handleRefreshTokenGrant( - request: Partial, - client: OIDCClient - ): Promise<{ success: true; response: TokenResponse } | { success: false; error: OIDCError }> { - if (!request.refresh_token) return OIDCErrorFactory.createTokenError('invalid_request', 'Missing refresh_token'); - - const refreshToken = await this.tokenManager.getRefreshToken(request.refresh_token); - if (!refreshToken) return OIDCErrorFactory.invalidGrant('Invalid refresh_token'); - - if (refreshToken.expires_at < new Date()) { - await this.tokenManager.deleteRefreshToken(request.refresh_token); - return OIDCErrorFactory.invalidGrant('Refresh token expired'); - } - - if (refreshToken.client_id !== request.client_id) return OIDCErrorFactory.invalidGrant('Refresh token was not issued to this client'); - - const user = await this.findUser(refreshToken.user_id); - if (!user) return OIDCErrorFactory.invalidGrant('User not found'); - - // 处理作用域 - let scope = refreshToken.scope; - if (request.scope) { - const requestedScopes = request.scope.split(' '); - const originalScopes = refreshToken.scope.split(' '); - if (!requestedScopes.every(s => originalScopes.includes(s))) { - return OIDCErrorFactory.invalidScope('Requested scope exceeds original scope'); - } - scope = request.scope; - } - - const now = new Date(); - const accessTokenJWT = await this.jwtUtils.generateAccessToken({ - issuer: this.config.issuer, - subject: user.sub, - audience: request.client_id!, - clientId: request.client_id!, - scope, - expiresIn: this.config.tokenTTL.accessToken, - }); - - await this.tokenManager.storeAccessToken({ - token: accessTokenJWT, - client_id: request.client_id!, - user_id: user.sub, - scope, - expires_at: new Date(now.getTime() + this.config.tokenTTL.accessToken * 1000), - created_at: now, - }); - - const response: TokenResponse = { - access_token: accessTokenJWT, - token_type: 'Bearer', - expires_in: this.config.tokenTTL.accessToken, scope, }; - // 刷新令牌轮换 - if (this.config.rotateRefreshTokens) { - const newRefreshToken = nanoid(64); - await this.tokenManager.storeRefreshToken({ - token: newRefreshToken, - client_id: request.client_id!, - user_id: user.sub, - scope, - expires_at: new Date(now.getTime() + this.config.tokenTTL.refreshToken * 1000), - created_at: now, - }); - await this.tokenManager.deleteRefreshToken(request.refresh_token); - response.refresh_token = newRefreshToken; - } - - return { success: true, response }; + if (idToken) response.id_token = idToken; + return response; } - async getUserInfo(accessToken: string): Promise<{ success: true; user: Partial } | { success: false; error: OIDCError }> { - try { - const payload = await this.jwtUtils.verifyToken(accessToken); - if ((payload as any).token_type !== 'access_token') { - return { success: false, error: OIDCErrorFactory.invalidToken('Invalid token type') }; - } - - const tokenData = await this.tokenManager.getAccessToken(accessToken); - if (!tokenData) return { success: false, error: OIDCErrorFactory.invalidToken('Token not found') }; - - if (tokenData.expires_at < new Date()) { - await this.tokenManager.deleteAccessToken(accessToken); - return { success: false, error: OIDCErrorFactory.invalidToken('Token expired') }; - } - - const user = await this.findUser(tokenData.user_id); - if (!user) return { success: false, error: OIDCErrorFactory.invalidToken('User not found') }; - - const requestedClaims = this.getRequestedClaims(tokenData.scope); - const filteredUser = this.filterUserClaims(user, requestedClaims); - - return { success: true, user: filteredUser }; - } catch (error) { - return { success: false, error: OIDCErrorFactory.invalidToken() }; + async getUserInfo(accessToken: string): Promise> { + const tokenData = await this.tokenManager.getAccessToken(accessToken); + if (!tokenData || tokenData.expires_at < new Date()) { + if (tokenData) await this.tokenManager.deleteAccessToken(accessToken); + throw new Error('Token无效或已过期'); } + + // 验证JWT + await this.jwtUtils.verifyToken(accessToken); + + const user = await this.findUser(tokenData.user_id); + if (!user) throw new Error('用户不存在'); + + const claims = this.getClaimsForScope(tokenData.scope); + return this.filterUserClaims(user, claims); } - private getRequestedClaims(scope: string): string[] { + private getClaimsForScope(scope: string): string[] { const scopes = scope.split(' '); - const claims = ['sub']; - - const scopeClaimsMap = { - profile: ['name', 'family_name', 'given_name', 'middle_name', 'nickname', 'preferred_username', 'profile', 'picture', 'website', 'gender', 'birthdate', 'zoneinfo', 'locale', 'updated_at'], + const claimsMap = { + profile: ['name', 'given_name', 'family_name', 'picture'], email: ['email', 'email_verified'], phone: ['phone_number', 'phone_number_verified'], address: ['address'], }; - scopes.forEach(scope => { - if (scope in scopeClaimsMap) { - claims.push(...scopeClaimsMap[scope as keyof typeof scopeClaimsMap]); + const claims = new Set(['sub']); + scopes.forEach(s => { + if (s in claimsMap) { + claimsMap[s as keyof typeof claimsMap].forEach(c => claims.add(c)); } }); - return claims; + return Array.from(claims); } - private filterUserClaims(user: OIDCUser, requestedClaims: string[]): Partial { + private filterUserClaims(user: OIDCUser, claims: string[]): Partial { return Object.fromEntries( - requestedClaims + claims .filter(claim => claim in user && user[claim as keyof OIDCUser] !== undefined) .map(claim => [claim, user[claim as keyof OIDCUser]]) - ) as Partial; + ); } - async revokeToken(token: string): Promise<{ success: boolean; error?: OIDCError }> { - try { - const { accessToken, refreshToken } = await this.findTokenData(token); + async revokeToken(token: string, clientId?: string): Promise { + const result = await this.findToken(token); + if (!result) return; // Token不存在或已过期 - if (accessToken) { - await this.tokenManager.deleteAccessToken(token); - } else if (refreshToken) { - await Promise.all([ - this.tokenManager.deleteRefreshToken(token), - this.tokenManager.deleteAccessTokensByUserAndClient(refreshToken.user_id, refreshToken.client_id) - ]); - } + const { tokenData, type } = result; - return { success: true }; - } catch (error) { - return { success: false, error: OIDCErrorFactory.createSimpleError('server_error', 'Internal server error') }; + if (clientId && tokenData.client_id !== clientId) { + throw new Error('Token不属于此客户端'); + } + + if (type === 'access') { + await this.tokenManager.deleteAccessToken(token); + } else { + // 撤销刷新token时,同时撤销相关的访问token + await Promise.all([ + this.tokenManager.deleteRefreshToken(token), + this.tokenManager.deleteAccessTokensByUserAndClient(tokenData.user_id, tokenData.client_id) + ]); } } - async introspectToken(token: string) { - try { - const { accessToken, refreshToken, tokenData, isExpired } = await this.findTokenData(token); + async introspectToken(token: string, clientId?: string) { + const result = await this.findToken(token); + if (!result) return { active: false }; - if (!tokenData || isExpired) return { active: false }; + const { tokenData, type } = result; - const user = await this.findUser(tokenData.user_id); - - return { - active: true, - scope: tokenData.scope, - client_id: tokenData.client_id, - username: user?.username, - token_type: accessToken ? 'Bearer' : 'refresh_token', - exp: Math.floor(tokenData.expires_at.getTime() / 1000), - iat: Math.floor(tokenData.created_at.getTime() / 1000), - sub: tokenData.user_id, - aud: tokenData.client_id, - iss: this.config.issuer, - }; - } catch (error) { + if (clientId && tokenData.client_id !== clientId) { return { active: false }; } + + const user = await this.findUser(tokenData.user_id); + + return { + active: true, + scope: tokenData.scope, + client_id: tokenData.client_id, + username: user?.username, + token_type: type === 'access' ? 'Bearer' : 'refresh_token', + exp: Math.floor(tokenData.expires_at.getTime() / 1000), + iat: Math.floor(tokenData.created_at.getTime() / 1000), + sub: tokenData.user_id, + aud: tokenData.client_id, + iss: this.config.issuer, + }; } - async getJWKS(): Promise<{ keys: any[] }> { - return await this.jwtUtils.generateJWKS(); + async getJWKS() { + return this.jwtUtils.generateJWKS(); + } + + // HTTP处理器 + private createErrorResponse(error: unknown): { error: string; error_description: string } { + const message = error instanceof Error ? error.message : '服务器内部错误'; + console.error('OIDC Provider Error:', error); + return { + error: 'invalid_request', + error_description: message + }; } - // HTTP处理方法 async handleLogin(c: Context): Promise { - const formData = await c.req.formData(); - const authRequest = Object.fromEntries( - ['response_type', 'client_id', 'redirect_uri', 'scope', 'state', 'nonce', 'code_challenge', 'code_challenge_method'] - .map(field => [field, formData.get(field)?.toString() || '']) - ) as any; - try { + const formData = await c.req.formData(); + const authRequest = Object.fromEntries( + ['response_type', 'client_id', 'redirect_uri', 'scope', 'state', 'nonce', 'code_challenge', 'code_challenge_method'] + .map(field => [field, formData.get(field)?.toString() || '']) + ); + const authResult = await this.passwordAuth.authenticate(c); return authResult.success ? await this.passwordAuth.handleAuthenticationSuccess(c, authResult) - : await this.passwordAuth.handleAuthenticationFailure(c, authResult, authRequest); + : await this.passwordAuth.handleAuthenticationFailure(c, authResult, authRequest as any); } catch (error) { - console.error('登录流程处理失败:', error); - return await this.passwordAuth.handleAuthenticationFailure( - c, { success: false, error: '服务器内部错误' }, authRequest - ); + return c.json(this.createErrorResponse(error), 500); } } async handleLogout(c: Context): Promise { - return await this.passwordAuth.logout(c); + return this.passwordAuth.logout(c); } async handleAuthorization(c: Context): Promise { - const authRequest = this.parseAuthorizationRequest(c.req.query()); - const userId = await this.passwordAuth.getCurrentUser(c); + try { + const authRequest = this.parseAuthRequest(c.req.query()); + const userId = await this.passwordAuth.getCurrentUser(c); - if (!userId) { - return await this.passwordAuth.handleAuthenticationRequired(c, authRequest); + if (!userId) { + return this.passwordAuth.handleAuthenticationRequired(c, authRequest); + } + + const result = await this.handleAuthorizationRequest(authRequest, userId); + const params = new URLSearchParams(); + if (result.code) params.set('code', result.code); + if (result.state) params.set('state', result.state); + + return c.redirect(`${authRequest.redirect_uri}?${params.toString()}`); + } catch (error) { + return c.json(this.createErrorResponse(error), 400); } - - const result = await this.handleAuthorizationRequest(authRequest, userId); - - if (!result.success) { - const errorParams = OIDCErrorFactory.buildErrorResponse(result.error); - const redirectUri = result.redirectUri || authRequest.redirect_uri; - - return redirectUri - ? c.redirect(`${redirectUri}?${errorParams.toString()}`) - : c.json({ - error: result.error.error, - error_description: result.error.error_description || 'Unknown error' - }, 400); - } - - const params = new URLSearchParams(); - params.set('code', result.code); - if (authRequest.state) params.set('state', authRequest.state); - return c.redirect(`${result.redirectUri}?${params.toString()}`); } async handleToken(c: Context): Promise { - const body = await c.req.formData(); - const tokenRequest = this.parseTokenRequest(body, c.req.header('Authorization')); - const result = await this.handleTokenRequest(tokenRequest); + try { + const body = await c.req.formData(); + const tokenRequest = this.parseTokenRequest(body, c.req.header('Authorization')); + const response = await this.handleTokenRequest(tokenRequest); - return result.success - ? c.json(result.response) - : c.json({ - error: result.error.error, - error_description: result.error.error_description || 'Unknown error' - }, 400); + c.header('Cache-Control', 'no-store'); + c.header('Pragma', 'no-cache'); + return c.json(response); + } catch (error) { + return c.json(this.createErrorResponse(error), 400); + } } async handleUserInfo(c: Context): Promise { - const authHeader = c.req.header('Authorization'); - if (!authHeader?.startsWith('Bearer ')) { - c.header('WWW-Authenticate', 'Bearer'); - return c.json(OIDCErrorFactory.invalidToken('无效的访问令牌'), 401); - } + try { + const authHeader = c.req.header('Authorization'); + if (!authHeader?.startsWith('Bearer ')) { + c.header('WWW-Authenticate', 'Bearer realm="userinfo"'); + return c.json({ error: 'invalid_token', error_description: 'Bearer token required' }, 401); + } - const result = await this.getUserInfo(authHeader.substring(7)); - if (!result.success) { - c.header('WWW-Authenticate', `Bearer error="${result.error.error}"`); - return c.json(result.error, 401); + const token = bearerTokenSchema.parse(authHeader); + const user = await this.getUserInfo(token); + return c.json(user); + } catch (error) { + c.header('WWW-Authenticate', 'Bearer realm="userinfo"'); + return c.json({ error: 'invalid_token', error_description: 'Invalid token' }, 401); } - - return c.json(result.user); } async handleRevoke(c: Context): Promise { - const body = await c.req.formData(); - const token = body.get('token')?.toString(); + try { + const body = await c.req.formData(); + const { token, client_id } = revokeTokenRequestSchema.parse({ + token: body.get('token')?.toString(), + client_id: body.get('client_id')?.toString(), + }); - if (!token) { - const error = OIDCErrorFactory.invalidRequest('缺少token参数'); - return c.json({ - error: error.error, - error_description: error.error_description || 'Invalid request' - }, 400); + if (!token) { + return c.json({ error: 'invalid_request', error_description: 'Missing token' }, 400); + } + + await this.revokeToken(token, client_id); + return c.body(null, 200); + } catch (error) { + return c.json(this.createErrorResponse(error), 400); } - - const result = await this.revokeToken(token); - if (!result.success && result.error) { - const error = result.error as OIDCError; - return c.json({ - error: error.error, - error_description: error.error_description || 'Unknown error' - }, 400); - } - - return c.body(null, 200); } async handleIntrospect(c: Context): Promise { - const body = await c.req.formData(); - const token = body.get('token')?.toString(); + try { + const body = await c.req.formData(); + const { token, client_id } = introspectTokenRequestSchema.parse({ + token: body.get('token')?.toString(), + client_id: body.get('client_id')?.toString(), + }); - if (!token) { - const error = OIDCErrorFactory.invalidRequest('缺少token参数'); - return c.json({ - error: error.error, - error_description: error.error_description || 'Invalid request' - }, 400); + if (!token) { + return c.json({ error: 'invalid_request', error_description: 'Missing token' }, 400); + } + + const result = await this.introspectToken(token, client_id); + return c.json(result); + } catch (error) { + return c.json(this.createErrorResponse(error), 400); } - - const result = await this.introspectToken(token); - return c.json(result); } } diff --git a/packages/oidc-provider/src/schemas/index.ts b/packages/oidc-provider/src/schemas/index.ts new file mode 100644 index 0000000..5ec1361 --- /dev/null +++ b/packages/oidc-provider/src/schemas/index.ts @@ -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 = {}; + 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 = {}; + 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 : '未知错误'}`); + } +}); + diff --git a/packages/oidc-provider/src/types/index.ts b/packages/oidc-provider/src/types/index.ts index 54c70cf..c26a2b8 100644 --- a/packages/oidc-provider/src/types/index.ts +++ b/packages/oidc-provider/src/types/index.ts @@ -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; /** 查找用户的回调函数 */ diff --git a/packages/oidc-provider/src/utils/jwt.ts b/packages/oidc-provider/src/utils/jwt.ts index b70793f..aba5faa 100644 --- a/packages/oidc-provider/src/utils/jwt.ts +++ b/packages/oidc-provider/src/utils/jwt.ts @@ -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 | 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 { + if (this.privateKey) return; + + if (!this.keyPromise) { + this.keyPromise = this.generateRSAKeys(); + } + await this.keyPromise; + } + + /** + * 生成RSA密钥 + */ + private async generateRSAKeys(): Promise { + 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 { - 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 { - 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 { - try { - const { payload } = await jwtVerify( - token, - new TextEncoder().encode(this.signingKey) - ); - return payload as JWTPayload; - } catch (error) { - throw new Error(`Invalid token: ${error instanceof Error ? error.message : 'Unknown error'}`); - } + await this.ensureKeys(); + const { payload } = await jwtVerify(token, this.publicKey); + return payload as JWTPayload; } /** - * 解码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 - return { keys: [] }; - } + await this.ensureKeys(); - /** - * 生成RSA密钥对(用于生产环境) - */ - static async generateRSAKeyPair(): Promise<{ - privateKey: string; - publicKey: string; - jwk: any; - }> { - const { privateKey, publicKey } = await generateKeyPair('RS256'); - const jwk = await exportJWK(publicKey); + // HMAC密钥不公开 + if (this.kid === 'hmac-key') { + return { keys: [] }; + } + 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 { + 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 { + const { privateKey, publicKey } = await generateKeyPair('RS256'); + return { privateKey, publicKey, kid }; + } } \ No newline at end of file diff --git a/packages/openapi/src/index.ts b/packages/openapi/src/index.ts index 00a5398..bf48804 100644 --- a/packages/openapi/src/index.ts +++ b/packages/openapi/src/index.ts @@ -1,4 +1 @@ -// 导出所有API schema -export * from './oidc'; -export * from './user'; export * from "./generate.schema" \ No newline at end of file diff --git a/packages/openapi/src/oidc/authorization.schema.ts b/packages/openapi/src/oidc/authorization.schema.ts deleted file mode 100644 index cefe3f4..0000000 --- a/packages/openapi/src/oidc/authorization.schema.ts +++ /dev/null @@ -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; -export type AuthorizationCodeParams = z.infer; \ No newline at end of file diff --git a/packages/openapi/src/oidc/client.schema.ts b/packages/openapi/src/oidc/client.schema.ts deleted file mode 100644 index 4b21066..0000000 --- a/packages/openapi/src/oidc/client.schema.ts +++ /dev/null @@ -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; -export type ClientResponse = z.infer; \ No newline at end of file diff --git a/packages/openapi/src/oidc/consent.schema.ts b/packages/openapi/src/oidc/consent.schema.ts deleted file mode 100644 index f8d5436..0000000 --- a/packages/openapi/src/oidc/consent.schema.ts +++ /dev/null @@ -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; -export type ConsentForm = z.infer; \ No newline at end of file diff --git a/packages/openapi/src/oidc/index.ts b/packages/openapi/src/oidc/index.ts deleted file mode 100644 index 301a273..0000000 --- a/packages/openapi/src/oidc/index.ts +++ /dev/null @@ -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'; \ No newline at end of file diff --git a/packages/openapi/src/oidc/session.schema.ts b/packages/openapi/src/oidc/session.schema.ts deleted file mode 100644 index b67f913..0000000 --- a/packages/openapi/src/oidc/session.schema.ts +++ /dev/null @@ -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; -export type CheckSessionRequest = z.infer; \ No newline at end of file diff --git a/packages/openapi/src/oidc/token.schema.ts b/packages/openapi/src/oidc/token.schema.ts deleted file mode 100644 index 59d7f1b..0000000 --- a/packages/openapi/src/oidc/token.schema.ts +++ /dev/null @@ -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; -export type TokenResponse = z.infer; -export type TokenIntrospectionRequest = z.infer; -export type TokenIntrospectionResponse = z.infer; -export type TokenRevocationRequest = z.infer; -export type TokenErrorResponse = z.infer; \ No newline at end of file diff --git a/packages/openapi/src/oidc/userinfo.schema.ts b/packages/openapi/src/oidc/userinfo.schema.ts deleted file mode 100644 index a5e9c7c..0000000 --- a/packages/openapi/src/oidc/userinfo.schema.ts +++ /dev/null @@ -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; \ No newline at end of file diff --git a/packages/openapi/src/user/index.ts b/packages/openapi/src/user/index.ts deleted file mode 100644 index e282561..0000000 --- a/packages/openapi/src/user/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./user.schema" \ No newline at end of file diff --git a/packages/openapi/src/user/user.schema.ts b/packages/openapi/src/user/user.schema.ts deleted file mode 100644 index 571af4c..0000000 --- a/packages/openapi/src/user/user.schema.ts +++ /dev/null @@ -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; -export type LoginUserDto = z.infer; -export type UserResponse = z.infer; -export type LoginResponse = z.infer; diff --git a/test-oidc.ts b/test-oidc.ts deleted file mode 100644 index 0519ecb..0000000 --- a/test-oidc.ts +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file