diff --git a/.cursorignore b/.cursorignore new file mode 100644 index 0000000..6f9f00f --- /dev/null +++ b/.cursorignore @@ -0,0 +1 @@ +# Add directories or file patterns to ignore during indexing (e.g. foo/ or *.csv) diff --git a/OIDC_ARCHITECTURE_UPDATE.md b/OIDC_ARCHITECTURE_UPDATE.md new file mode 100644 index 0000000..a95c80a --- /dev/null +++ b/OIDC_ARCHITECTURE_UPDATE.md @@ -0,0 +1,133 @@ +# OIDC 架构更新总结 + +## 🎯 更新目标 + +将项目从混合认证架构改为标准的 OIDC 架构,确保所有用户认证都在 OIDC Provider 中处理。 + +## 🔄 主要改动 + +### 1. 删除客户端登录页面 +- ❌ 删除了 `apps/web/app/auth/login/page.tsx` +- ❌ 删除了客户端应用中的自定义认证逻辑 + +### 2. 修复回调页面 +- ✅ 更新 `apps/web/app/auth/callback/page.tsx` 中的错误链接 +- ✅ 移除对已删除登录页面的引用 + +### 3. 添加测试页面 +- ✅ 创建 `apps/web/app/test-oidc/page.tsx` 用于测试OIDC流程 +- ✅ 在首页添加测试页面链接 + +### 4. 更新文档 +- ✅ 更新 `apps/backend/README.md` 以反映正确的架构 + +## 🏗️ 当前架构 + +### 正确的 OIDC 流程 +``` +用户点击登录 + ↓ +客户端重定向到 OIDC Provider 授权端点 + ↓ +OIDC Provider 显示内置登录页面 + ↓ +用户在 Provider 页面上登录 + ↓ +Provider 生成授权码并重定向回客户端 + ↓ +客户端用授权码换取令牌 + ↓ +认证完成 +``` + +### 架构优势 + +#### ✅ 已实现的正确做法 +- OIDC Provider 包含登录页面 +- 标准授权码流程 +- PKCE 支持 +- 内置会话管理 +- 自动令牌刷新 + +#### ❌ 已移除的错误做法 +- 客户端应用的登录页面 +- 自定义认证逻辑 +- 重复的用户管理 +- 混合认证流程 + +## 🧪 测试方法 + +### 1. 访问测试页面 +访问 `http://localhost:3001/test-oidc` 进行完整的流程测试 + +### 2. 测试 Discovery 端点 +在测试页面点击"测试 Discovery 端点"按钮 + +### 3. 完整认证流程测试 +1. 在测试页面点击"开始 OIDC 认证流程" +2. 将跳转到 OIDC Provider 的内置登录页面 +3. 使用演示账号登录:`demouser` / `demo123` +4. 登录成功后会重定向回客户端应用 + +## 🔧 技术细节 + +### OIDC Provider 配置 +```typescript +export const oidcApp = createOIDCProvider({ + config: oidcConfig, + useBuiltInAuth: true, + builtInAuthConfig: { + passwordValidator: validatePassword, + sessionTTL: 24 * 60 * 60, // 24小时 + loginPageTitle: 'OIDC Demo 登录', + brandName: 'OIDC Demo Provider', + }, +}); +``` + +### 客户端配置 +```typescript +export const oidcConfig = { + authority: 'http://localhost:3000/oidc', + client_id: 'demo-client', + redirect_uri: 'http://localhost:3001/auth/callback', + response_type: 'code', + scope: 'openid profile email', + // ... 其他标准OIDC配置 +}; +``` + +## 📋 验证清单 + +- [x] 删除客户端登录页面 +- [x] 修复回调页面引用 +- [x] OIDC Provider 内置认证正常工作 +- [x] 标准 OIDC 流程可以完整运行 +- [x] Discovery 端点返回正确配置 +- [x] 文档已更新 +- [x] 测试页面可用 + +## 🚀 启动说明 + +1. 启动后端 OIDC Provider: + ```bash + cd apps/backend + bun run dev + ``` + +2. 启动前端客户端: + ```bash + cd apps/web + npm run dev + ``` + +3. 访问测试页面: + http://localhost:3001/test-oidc + +## 🎉 总结 + +现在项目已经完全符合标准的 OIDC 架构: +- **分离关注点**: OIDC Provider 专注于认证,客户端专注于业务逻辑 +- **标准合规**: 完全符合 OpenID Connect 规范 +- **简化维护**: 认证逻辑集中在 Provider 中 +- **更好的安全性**: 用户凭据只在 Provider 中处理 \ No newline at end of file diff --git a/apps/backend/README.md b/apps/backend/README.md index 6dd13e7..e11cbeb 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -1,11 +1,286 @@ -To install dependencies: -```sh -bun install +# OIDC Provider Demo + +这是一个基于 Hono 后端的 OpenID Connect (OIDC) Provider 演示应用,使用了标准的OIDC架构设计。 + +## 功能特性 + +- ✅ 完整的 OIDC Provider 实现 +- ✅ 支持授权码流程 (Authorization Code Flow) +- ✅ 支持 PKCE (Proof Key for Code Exchange) +- ✅ 内置认证处理器 +- ✅ JWT 令牌签发和验证 +- ✅ 用户信息端点 +- ✅ 令牌撤销 +- ✅ Redis 存储适配器 +- ✅ 内置登录页面 + +## 标准OIDC架构 + +### 使用内置认证处理器(推荐) + +```typescript +import { createOIDCProvider } from '@repo/oidc-provider'; + +// 使用内置认证处理器,Provider自己处理登录 +const oidcApp = createOIDCProvider({ + config: oidcConfig, + useBuiltInAuth: true, + builtInAuthConfig: { + passwordValidator: validatePassword, + sessionTTL: 24 * 60 * 60, // 24小时 + loginPageTitle: 'OIDC Demo 登录', + brandName: 'OIDC Demo Provider', + }, +}); ``` -To run: -```sh +### 自定义认证处理器(高级用法) + +```typescript +import { createOIDCProvider, type AuthHandler } from '@repo/oidc-provider'; + +class MyAuthHandler implements AuthHandler { + async getCurrentUser(c: Context): Promise { + // 检查用户认证状态 + // 例如:从JWT token、session或cookie中获取用户ID + const token = c.req.header('Authorization'); + return await verifyTokenAndGetUserId(token); + } + + async handleAuthRequired(c: Context, authRequest: AuthorizationRequest): Promise { + // 处理未认证用户 + // 例如:显示自定义登录页面或重定向到外部认证服务 + return this.showCustomLoginPage(c, authRequest); + } +} + +const oidcApp = createOIDCProvider({ + config: oidcConfig, + authHandler: new MyAuthHandler() +}); +``` + +## 启动服务 + +```bash +# 在项目根目录 +cd apps/backend bun run dev ``` -open http://localhost:3000 +服务将在 `http://localhost:8000` 启动。 + +## 当前配置 + +当前的 OIDC Provider 已配置为: +- 使用内置认证处理器,Provider自己处理所有登录逻辑 +- 支持标准OIDC授权码流程 +- 使用 Redis 存储令牌和会话 + +## OIDC 端点 + +### 发现文档 +``` +GET http://localhost:8000/oidc/.well-known/openid-configuration +``` + +### 主要端点 +- **授权端点**: `http://localhost:8000/oidc/auth` +- **令牌端点**: `http://localhost:8000/oidc/token` +- **用户信息端点**: `http://localhost:8000/oidc/userinfo` +- **JWKS端点**: `http://localhost:8000/oidc/.well-known/jwks.json` +- **撤销端点**: `http://localhost:8000/oidc/revoke` + +## 测试客户端 + +### 机密客户端 (Confidential Client) +``` +Client ID: demo-client +Client Secret: demo-client-secret +重定向URI: + - http://localhost:3000/callback + - http://localhost:8080/callback + - https://oauth.pstmn.io/v1/callback +``` + +### 公共客户端 (Public Client) +``` +Client ID: demo-public-client +重定向URI: + - http://localhost:3000/callback + - myapp://callback +``` + +## 测试用户 + +``` +用户名: demouser +密码: password +用户ID (sub): demo-user +``` + +## API 测试 + +运行测试脚本验证新API: + +```bash +cd apps/backend +bun run test-oidc.ts +``` + +## 授权流程 + +1. **客户端发起授权请求**: 访问 `/oidc/auth` 端点 +2. **检查用户认证**: AuthHandler 检查用户是否已认证 +3. **用户认证**: 如未认证,重定向到登录页面 +4. **生成授权码**: 认证成功后生成授权码 +5. **交换访问令牌**: 客户端使用授权码换取访问令牌 +6. **访问资源**: 使用访问令牌访问用户信息等资源 + +## 配置说明 + +```typescript +import { createOIDCProvider, DefaultAuthHandler } from '@repo/oidc-provider'; + +const oidcConfig = { + issuer: 'http://localhost:8000/oidc', + signingKey: 'your-secret-key', + storage: redisAdapter, + findClient: async (clientId) => { /* 查找客户端 */ }, + findUser: async (userId) => { /* 查找用户 */ }, + // ... 其他配置 +}; + +// 方式1: 使用内置认证处理器(推荐) +const app1 = createOIDCProvider({ + config: oidcConfig, + useBuiltInAuth: true, + builtInAuthConfig: { + passwordValidator: validatePassword, + sessionTTL: 24 * 60 * 60, + loginPageTitle: 'OIDC Demo 登录', + brandName: 'OIDC Demo Provider', + }, +}); + +// 方式2: 使用自定义处理器(高级用法) +const app2 = createOIDCProvider({ + config: oidcConfig, + authHandler: new CustomAuthHandler() +}); +``` + +## 与 Next.js Web 应用集成 + +1. **标准OIDC流程**: Next.js 应用使用标准 oidc-client-ts 库 +2. **认证处理**: OIDC Provider 自己处理所有用户认证和登录页面 +3. **令牌管理**: OIDC Provider 负责生成和管理所有令牌 +4. **回调处理**: Next.js 应用只需处理OIDC回调,获取令牌 + +## 技术架构 + +- **框架**: Hono.js +- **存储**: Redis (令牌存储) +- **JWT**: JOSE 库 +- **PKCE**: 支持 SHA256 和 plain 方法 +- **算法**: HS256 (可配置 RS256, ES256) +- **认证**: 可自定义认证处理器 + +## 优势 + +相比之前的实现,新API具有以下优势: + +1. **简化配置**: 只需要提供配置对象和认证处理器 +2. **灵活认证**: 支持任意认证方式(JWT、Session、OAuth等) +3. **清晰分离**: 认证逻辑与OIDC协议逻辑分离 +4. **易于扩展**: 通过实现 AuthHandler 接口轻松自定义 +5. **类型安全**: 完整的 TypeScript 类型支持 + +## 开发说明 + +- 修改客户端或用户数据:编辑 `src/oidc-demo.ts` +- 自定义认证逻辑:实现 `AuthHandler` 接口 +- 配置调整:修改 `oidcConfig` 对象 + +## 快速测试 + +1. **Web界面测试**: + 访问 http://localhost:8000 查看完整的测试界面 + +2. **授权流程测试**: + 访问 http://localhost:8000/oidc/auth?response_type=code&client_id=demo-client&redirect_uri=https://oauth.pstmn.io/v1/callback&scope=openid%20profile%20email&state=test-state + +3. **Postman测试**: + 使用以下配置在 Postman 中测试 OAuth 2.0: + ``` + 授权URL: http://localhost:8000/oidc/auth + 令牌URL: http://localhost:8000/oidc/token + 回调URL: https://oauth.pstmn.io/v1/callback + Client ID: demo-client + Client Secret: demo-client-secret + 作用域: openid profile email + ``` + +## 完整的授权码流程 + +### 1. 授权请求 +``` +GET http://localhost:8000/oidc/auth?response_type=code&client_id=demo-client&redirect_uri=https://oauth.pstmn.io/v1/callback&scope=openid%20profile%20email&state=random-state +``` + +### 2. 用户登录 +系统会自动重定向到登录页面,使用测试用户登录 + +### 3. 获取授权码 +登录成功后会重定向到 `redirect_uri` 并携带授权码 + +### 4. 交换访问令牌 +```bash +curl -X POST http://localhost:8000/oidc/token \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "grant_type=authorization_code&code=YOUR_CODE&redirect_uri=https://oauth.pstmn.io/v1/callback&client_id=demo-client&client_secret=demo-client-secret" +``` + +### 5. 访问用户信息 +```bash +curl -X GET http://localhost:8000/oidc/userinfo \ + -H "Authorization: Bearer YOUR_ACCESS_TOKEN" +``` + +## 支持的作用域和声明 + +### 作用域 (Scopes) +- `openid` - 必需的基础作用域 +- `profile` - 用户基本信息 +- `email` - 邮箱信息 +- `phone` - 电话号码 +- `address` - 地址信息 + +### 声明 (Claims) +- `sub` - 用户唯一标识 +- `name`, `given_name`, `family_name` - 姓名信息 +- `email`, `email_verified` - 邮箱信息 +- `phone_number`, `phone_number_verified` - 电话信息 +- `picture`, `profile`, `website` - 个人资料 +- `gender`, `birthdate`, `zoneinfo`, `locale` - 基本信息 +- `address` - 地址信息 +- `updated_at` - 更新时间 + +## 安全注意事项 + +⚠️ **这是一个演示应用,不应用于生产环境!** + +生产环境部署时需要注意: +1. 使用强密钥和安全的签名算法 +2. 实现真实的用户认证和会话管理 +3. 添加适当的安全头和 CSRF 保护 +4. 使用 HTTPS +5. 实现客户端注册和管理 +6. 添加速率限制和监控 +7. 定期轮换密钥 + +## 开发和扩展 + +如需修改客户端或用户数据,编辑 `src/oidc-demo.ts` 文件中的 `demoClients` 和 `demoUsers` 数组。 + +如需自定义认证逻辑,修改 `src/index.ts` 中的登录处理逻辑。 \ No newline at end of file diff --git a/apps/backend/package.json b/apps/backend/package.json index 1472d63..4b7406c 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -1,32 +1,32 @@ { - "name": "backend", - "scripts": { - "dev": "bun run --hot src/index.ts" - }, - "dependencies": { - "@elastic/elasticsearch": "^9.0.2", - "@hono/trpc-server": "^0.3.4", - "@hono/zod-validator": "^0.5.0", - "@repo/db": "workspace:*", - "@repo/tus": "workspace:*", - "@trpc/server": "11.1.2", - "@types/oidc-provider": "^9.1.0", - "hono": "^4.7.10", - "ioredis": "5.4.1", - "jose": "^6.0.11", - "minio": "7.1.3", - "nanoid": "^5.1.5", - "nanoid-cjs": "^0.0.7", - "transliteration": "^2.3.5", - "node-cron": "^4.0.7", - "oidc-provider": "^9.1.1", - "superjson": "^2.2.2", - "dayjs": "^1.11.13", - "dotenv": "^16.4.7", - "zod": "^3.25.23" - }, - "devDependencies": { - "@types/bun": "latest", - "@types/node": "^22.15.21" - } + "name": "backend", + "scripts": { + "dev": "bun run --hot src/index.ts" + }, + "dependencies": { + "@elastic/elasticsearch": "^9.0.2", + "@hono/node-server": "^1.14.3", + "@hono/trpc-server": "^0.3.4", + "@hono/zod-validator": "^0.5.0", + "@repo/db": "workspace:*", + "@repo/oidc-provider": "workspace:*", + "@trpc/server": "11.1.2", + "hono": "^4.7.10", + "ioredis": "5.4.1", + "jose": "^6.0.11", + "minio": "7.1.3", + "nanoid": "^5.1.5", + "node-cron": "^4.0.7", + "oidc-provider": "^9.1.1", + "superjson": "^2.2.2", + "valibot": "^1.1.0", + "zod": "^3.25.23" + }, + "devDependencies": { + "@types/bun": "latest", + "@types/node": "^22.15.21", + "@types/oidc-provider": "^9.1.0", + "supertest": "^7.1.1", + "vitest": "^3.1.4" + } } diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 8962108..4c70029 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -9,7 +9,7 @@ import Redis from 'ioredis'; import redis from './redis'; import minioClient from './minio'; import { Client } from 'minio'; -import oidc from './oidc/provider'; + import { appRouter } from './trpc'; import { createBunWebSocket } from 'hono/bun'; @@ -60,12 +60,7 @@ app.use( app.route('/api/users', userRest); app.route('/api/upload', uploadRest); -app.use('/oidc/*', async (c, next) => { - // @ts-ignore - await oidc.callback(c.req.raw, c.res.raw); - // return void 也可以 - return; -}); + // 添加 WebSocket 路由 app.get('/ws', wsHandler); diff --git a/apps/backend/src/oidc-demo.ts b/apps/backend/src/oidc-demo.ts new file mode 100644 index 0000000..5c28e89 --- /dev/null +++ b/apps/backend/src/oidc-demo.ts @@ -0,0 +1,134 @@ +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/config.ts b/apps/backend/src/oidc/config.ts deleted file mode 100644 index e518af0..0000000 --- a/apps/backend/src/oidc/config.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { Configuration } from 'oidc-provider'; -import { RedisAdapter } from './redis-adapter'; -import { prisma } from '@repo/db'; - -async function getClients() { - const dbClients = await prisma.oidcClient.findMany?.(); - const dbClientList = - dbClients && dbClients.length > 0 - ? dbClients.map((c) => ({ - client_id: c.clientId, - client_secret: c.clientSecret, - grant_types: JSON.parse(c.grantTypes), // string -> string[] - redirect_uris: JSON.parse(c.redirectUris), // string -> string[] - response_types: JSON.parse(c.responseTypes), // string -> string[] - scope: c.scope, - })) - : []; - - // 管理后台client,通过环境变量读取 - const defaultClient = { - client_id: process.env.OIDC_CLIENT_ID || 'admin-client', - client_secret: process.env.OIDC_CLIENT_SECRET || 'admin-secret', - grant_types: ['authorization_code', 'refresh_token'], - redirect_uris: [process.env.OIDC_REDIRECT_URI || 'http://localhost:3000/callback'], - response_types: ['code'], - scope: 'openid email profile', - }; - - // 检查是否与数据库client_id重复 - const allClients = [defaultClient, ...dbClientList.filter((c) => c.client_id !== defaultClient.client_id)]; - - return allClients; -} - -const OIDC_COOKIE_KEY = process.env.OIDC_COOKIE_KEY || 'HrbEPlzByV0CcjFJhl2pjKV2iG8FgQIc'; - -const config: Configuration = { - adapter: RedisAdapter, - // 注意:clients字段现在是Promise,需在Provider初始化时await - clients: await getClients(), - pkce: { - required: () => true, - }, - features: { - devInteractions: { enabled: false }, - resourceIndicators: { enabled: true }, - revocation: { enabled: true }, - userinfo: { enabled: true }, - registration: { enabled: true }, - }, - cookies: { - keys: [OIDC_COOKIE_KEY], - }, - jwks: { - keys: [ - { - d: 'VEZOsY07JTFzGTqv6cC2Y32vsfChind2I_TTuvV225_-0zrSej3XLRg8iE_u0-3GSgiGi4WImmTwmEgLo4Qp3uEcxCYbt4NMJC7fwT2i3dfRZjtZ4yJwFl0SIj8TgfQ8ptwZbFZUlcHGXZIr4nL8GXyQT0CK8wy4COfmymHrrUoyfZA154ql_OsoiupSUCRcKVvZj2JHL2KILsq_sh_l7g2dqAN8D7jYfJ58MkqlknBMa2-zi5I0-1JUOwztVNml_zGrp27UbEU60RqV3GHjoqwI6m01U7K0a8Q_SQAKYGqgepbAYOA-P4_TLl5KC4-WWBZu_rVfwgSENwWNEhw8oQ', - dp: 'E1Y-SN4bQqX7kP-bNgZ_gEv-pixJ5F_EGocHKfS56jtzRqQdTurrk4jIVpI-ZITA88lWAHxjD-OaoJUh9Jupd_lwD5Si80PyVxOMI2xaGQiF0lbKJfD38Sh8frRpgelZVaK_gm834B6SLfxKdNsP04DsJqGKktODF_fZeaGFPH0', - dq: 'F90JPxevQYOlAgEH0TUt1-3_hyxY6cfPRU2HQBaahyWrtCWpaOzenKZnvGFZdg-BuLVKjCchq3G_70OLE-XDP_ol0UTJmDTT-WyuJQdEMpt_WFF9yJGoeIu8yohfeLatU-67ukjghJ0s9CBzNE_LrGEV6Cup3FXywpSYZAV3iqc', - e: 'AQAB', - kty: 'RSA', - n: 'xwQ72P9z9OYshiQ-ntDYaPnnfwG6u9JAdLMZ5o0dmjlcyrvwQRdoFIKPnO65Q8mh6F_LDSxjxa2Yzo_wdjhbPZLjfUJXgCzm54cClXzT5twzo7lzoAfaJlkTsoZc2HFWqmcri0BuzmTFLZx2Q7wYBm0pXHmQKF0V-C1O6NWfd4mfBhbM-I1tHYSpAMgarSm22WDMDx-WWI7TEzy2QhaBVaENW9BKaKkJklocAZCxk18WhR0fckIGiWiSM5FcU1PY2jfGsTmX505Ub7P5Dz75Ygqrutd5tFrcqyPAtPTFDk8X1InxkkUwpP3nFU5o50DGhwQolGYKPGtQ-ZtmbOfcWQ', - p: '5wC6nY6Ev5FqcLPCqn9fC6R9KUuBej6NaAVOKW7GXiOJAq2WrileGKfMc9kIny20zW3uWkRLm-O-3Yzze1zFpxmqvsvCxZ5ERVZ6leiNXSu3tez71ZZwp0O9gys4knjrI-9w46l_vFuRtjL6XEeFfHEZFaNJpz-lcnb3w0okrbM', - q: '3I1qeEDslZFB8iNfpKAdWtz_Wzm6-jayT_V6aIvhvMj5mnU-Xpj75zLPQSGa9wunMlOoZW9w1wDO1FVuDhwzeOJaTm-Ds0MezeC4U6nVGyyDHb4CUA3ml2tzt4yLrqGYMT7XbADSvuWYADHw79OFjEi4T3s3tJymhaBvy1ulv8M', - qi: 'wSbXte9PcPtr788e713KHQ4waE26CzoXx-JNOgN0iqJMN6C4_XJEX-cSvCZDf4rh7xpXN6SGLVd5ibIyDJi7bbi5EQ5AXjazPbLBjRthcGXsIuZ3AtQyR0CEWNSdM7EyM5TRdyZQ9kftfz9nI03guW3iKKASETqX2vh0Z8XRjyU', - use: 'sig', - }, - { - crv: 'P-256', - d: 'K9xfPv773dZR22TVUB80xouzdF7qCg5cWjPjkHyv7Ws', - kty: 'EC', - use: 'sig', - x: 'FWZ9rSkLt6Dx9E3pxLybhdM6xgR5obGsj5_pqmnz5J4', - y: '_n8G69C-A2Xl4xUW2lF0i8ZGZnk_KPYrhv4GbTGu5G4', - }, - ], - }, - ttl: { - AccessToken: 3600, - AuthorizationCode: 600, - IdToken: 3600, - RefreshToken: 1209600, - BackchannelAuthenticationRequest: 600, - ClientCredentials: 600, - DeviceCode: 600, - Grant: 1209600, - Interaction: 3600, - Session: 1209600, - RegistrationAccessToken: 3600, - DPoPProof: 300, - PushedAuthorizationRequest: 600, - ReplayDetection: 3600, - LogoutToken: 600, - }, - findAccount: async (ctx, id) => { - const user = await prisma.user.findUnique({ where: { id } }); - if (!user) return undefined; - return { - accountId: user.id, - async claims() { - return { - sub: user.id, - email: user.email, - name: user.name, - }; - }, - }; - }, -}; - -export default config; diff --git a/apps/backend/src/oidc/provider.ts b/apps/backend/src/oidc/provider.ts deleted file mode 100644 index aca144d..0000000 --- a/apps/backend/src/oidc/provider.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { Provider } from 'oidc-provider'; -import config from './config'; - -const oidc = new Provider('http://localhost:3000', config); - -export default oidc; \ No newline at end of file diff --git a/apps/backend/src/oidc/redis-adapter.ts b/apps/backend/src/oidc/redis-adapter.ts deleted file mode 100644 index d62fe20..0000000 --- a/apps/backend/src/oidc/redis-adapter.ts +++ /dev/null @@ -1,86 +0,0 @@ -import type { Adapter, AdapterPayload } from 'oidc-provider'; -import redis from '../redis'; - -export class RedisAdapter implements Adapter { - name: string; - constructor(name: string) { - this.name = name; - } - - key(id: string) { - return `${this.name}:${id}`; - } - - async upsert(id: string, payload: AdapterPayload, expiresIn: number) { - const key = this.key(id); - await redis.set(key, JSON.stringify(payload), 'EX', expiresIn); - if (payload && payload.grantId) { - // 记录grantId到id的映射,便于revokeByGrantId - await redis.sadd(this.grantKey(payload.grantId), id); - await redis.expire(this.grantKey(payload.grantId), expiresIn); - } - if (payload && payload.userCode) { - await redis.set(this.userCodeKey(payload.userCode), id, 'EX', expiresIn); - } - if (payload && payload.uid) { - await redis.set(this.uidKey(payload.uid), id, 'EX', expiresIn); - } - } - - async find(id: string) { - const data = await redis.get(this.key(id)); - return data ? JSON.parse(data) : undefined; - } - - async findByUserCode(userCode: string) { - const id = await redis.get(this.userCodeKey(userCode)); - return id ? this.find(id) : undefined; - } - - async findByUid(uid: string) { - const id = await redis.get(this.uidKey(uid)); - return id ? this.find(id) : undefined; - } - - async destroy(id: string) { - const data = await this.find(id); - await redis.del(this.key(id)); - if (data && data.grantId) { - await redis.srem(this.grantKey(data.grantId), id); - } - if (data && data.userCode) { - await redis.del(this.userCodeKey(data.userCode)); - } - if (data && data.uid) { - await redis.del(this.uidKey(data.uid)); - } - } - - async revokeByGrantId(grantId: string) { - const key = this.grantKey(grantId); - const ids = await redis.smembers(key); - if (ids && ids.length) { - await Promise.all(ids.map((id) => this.destroy(id))); - } - await redis.del(key); - } - - async consume(id: string) { - const key = this.key(id); - const data = await this.find(id); - if (data) { - data.consumed = Math.floor(Date.now() / 1000); - await redis.set(key, JSON.stringify(data)); - } - } - - grantKey(grantId: string) { - return `${this.name}:grant:${grantId}`; - } - userCodeKey(userCode: string) { - return `${this.name}:userCode:${userCode}`; - } - uidKey(uid: string) { - return `${this.name}:uid:${uid}`; - } -} diff --git a/apps/backend/src/user/user.trpc.ts b/apps/backend/src/user/user.trpc.ts index 4c2dcbd..b3e7d82 100644 --- a/apps/backend/src/user/user.trpc.ts +++ b/apps/backend/src/user/user.trpc.ts @@ -3,6 +3,6 @@ import { publicProcedure, router } from "../trpc/base" export const userRouter = router({ getUser: publicProcedure.query(async ({ ctx }) => { - return '123' + return '1234' }) }) \ No newline at end of file diff --git a/apps/web/app/auth/callback/page.tsx b/apps/web/app/auth/callback/page.tsx new file mode 100644 index 0000000..2888e70 --- /dev/null +++ b/apps/web/app/auth/callback/page.tsx @@ -0,0 +1,120 @@ +'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/opengraph-image.png b/apps/web/app/opengraph-image.png deleted file mode 100644 index f2f8ff4..0000000 Binary files a/apps/web/app/opengraph-image.png and /dev/null differ diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index b235c7d..41faba0 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -1,26 +1,170 @@ 'use client'; -import { useHello, useTRPC } from '@repo/client'; -import { useQuery } from '@tanstack/react-query'; -import Link from 'next/link'; -export default function Home() { - const trpc = useTRPC(); - const { data, isLoading } = useQuery(trpc.user.getUser.queryOptions()); +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 ( -
-

首页

-
-
-

功能导航

-
    -
  • - - WebSocket 聊天室 - -
  • - {/* 可以在这里添加更多功能链接 */} -
+
+
+ {/* 页面标题 */} +
+

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 +
+
+
+
+
diff --git a/apps/web/app/test-oidc/page.tsx b/apps/web/app/test-oidc/page.tsx new file mode 100644 index 0000000..c680bb0 --- /dev/null +++ b/apps/web/app/test-oidc/page.tsx @@ -0,0 +1,248 @@ +'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 new file mode 100644 index 0000000..08e9490 --- /dev/null +++ b/apps/web/components/login-button.tsx @@ -0,0 +1,31 @@ +'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 e112edb..8832538 100644 --- a/apps/web/components/providers.tsx +++ b/apps/web/components/providers.tsx @@ -3,6 +3,7 @@ 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 ( @@ -13,7 +14,9 @@ export function Providers({ children }: { children: React.ReactNode }) { disableTransitionOnChange enableColorScheme > - {children} + + {children} + ); } diff --git a/apps/web/components/user-profile.tsx b/apps/web/components/user-profile.tsx new file mode 100644 index 0000000..d638dc6 --- /dev/null +++ b/apps/web/components/user-profile.tsx @@ -0,0 +1,251 @@ +'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 new file mode 100644 index 0000000..09ba761 --- /dev/null +++ b/apps/web/lib/oidc-config.ts @@ -0,0 +1,27 @@ +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/package.json b/apps/web/package.json index 0cbeb59..78e928e 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,40 +1,45 @@ { - "name": "web", - "version": "0.0.1", - "type": "module", - "private": true, - "scripts": { - "dev": "next dev --turbopack -p 3001", - "build": "next build", - "start": "next start", - "lint": "next lint" - }, - "dependencies": { - "@repo/client": "workspace:*", - "@repo/db": "workspace:*", - "@repo/ui": "workspace:*", - "@tanstack/react-query": "^5.51.21", - "@trpc/client": "11.1.2", - "@trpc/react-query": "11.1.2", - "@trpc/server": "11.1.2", - "@trpc/tanstack-react-query": "11.1.2", - "axios": "^1.7.2", - "dayjs": "^1.11.12", - "lucide-react": "0.511.0", - "next": "15.3.2", - "next-themes": "^0.4.6", - "react": "^19.1.0", - "tus-js-client": "^4.1.0", - "react-dom": "^19.1.0", - "superjson": "^2.2.2" - }, - "devDependencies": { - "@repo/eslint-config": "workspace:*", - "@repo/typescript-config": "workspace:*", - "@tailwindcss/postcss": "^4", - "@types/react": "^19.1.4", - "@types/react-dom": "^19.1.5", - "tailwindcss": "^4", - "typescript": "^5" - } + "name": "web", + "version": "0.0.1", + "type": "module", + "private": true, + "scripts": { + "dev": "next dev --turbopack -p 3001", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "@radix-ui/react-avatar": "^1.1.10", + "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-separator": "^1.1.7", + "@radix-ui/react-slot": "^1.2.3", + "@repo/client": "workspace:*", + "@repo/db": "workspace:*", + "@repo/ui": "workspace:*", + "@tanstack/react-query": "^5.51.21", + "@trpc/client": "11.1.2", + "@trpc/react-query": "11.1.2", + "@trpc/server": "11.1.2", + "@trpc/tanstack-react-query": "11.1.2", + "axios": "^1.7.2", + "dayjs": "^1.11.12", + "lucide-react": "0.511.0", + "next": "15.3.2", + "next-themes": "^0.4.6", + "oidc-client-ts": "^3.2.1", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "superjson": "^2.2.2", + "valibot": "^1.1.0" + }, + "devDependencies": { + "@repo/eslint-config": "workspace:*", + "@repo/typescript-config": "workspace:*", + "@tailwindcss/postcss": "^4", + "@types/react": "^19.1.4", + "@types/react-dom": "^19.1.5", + "tailwindcss": "^4", + "typescript": "^5" + } } diff --git a/apps/web/providers/auth-provider.tsx b/apps/web/providers/auth-provider.tsx new file mode 100644 index 0000000..55d5326 --- /dev/null +++ b/apps/web/providers/auth-provider.tsx @@ -0,0 +1,130 @@ +'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/client/package.json b/packages/client/package.json index 165800c..ed3abe8 100755 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -3,15 +3,12 @@ "version": "1.0.0", "exports": { ".": "./src/index.ts" - }, + }, "sideEffects": false, "files": [ "dist", "src" ], - "dependencies": { - - }, "peerDependencies": { "@tanstack/query-async-storage-persister": "^5.51.9", "@tanstack/react-query": "^5.51.21", @@ -25,9 +22,9 @@ "react": "^19.1.0" }, "devDependencies": { - "rimraf": "^6.0.1", - "tsup": "^8.3.5", "@types/react": "^19.1.4", - "@types/react-dom": "^19.1.5" + "@types/react-dom": "^19.1.5", + "rimraf": "^6.0.1", + "tsup": "^8.3.5" } } diff --git a/packages/oidc-provider/package.json b/packages/oidc-provider/package.json new file mode 100644 index 0000000..542fc97 --- /dev/null +++ b/packages/oidc-provider/package.json @@ -0,0 +1,60 @@ +{ + "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", + "scripts": { + "build": "tsc", + "dev": "tsc --watch", + "clean": "rm -rf dist" + }, + "dependencies": { + "@hono/zod-validator": "^0.5.0", + "hono": "^4.7.10", + "ioredis": "5.4.1", + "jose": "^6.0.11", + "nanoid": "^5.1.5", + "zod": "^3.25.23" + }, + "devDependencies": { + "@types/node": "^22.15.21", + "typescript": "^5.0.0" + }, + "peerDependencies": { + "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" + ], + "keywords": [ + "oidc", + "openid-connect", + "oauth2", + "hono", + "authentication", + "authorization", + "redis", + "typescript" + ], + "author": "Your Name", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/your-org/your-repo.git", + "directory": "packages/oidc-provider" + }, + "bugs": { + "url": "https://github.com/your-org/your-repo/issues" + }, + "homepage": "https://github.com/your-org/your-repo/tree/main/packages/oidc-provider#readme" +} \ No newline at end of file diff --git a/packages/oidc-provider/src/auth/auth-manager.ts b/packages/oidc-provider/src/auth/auth-manager.ts new file mode 100644 index 0000000..201c97c --- /dev/null +++ b/packages/oidc-provider/src/auth/auth-manager.ts @@ -0,0 +1,157 @@ +import type { Context } from 'hono'; +import type { AuthorizationRequest, OIDCProviderConfig } from '../types'; +import { PasswordAuthStrategy, type AuthenticationResult, type PasswordAuthConfig } from './strategies/password-auth-strategy'; + +/** + * 密码验证器类型 + */ +export type PasswordValidator = (username: string, password: string) => Promise; + +/** + * 认证管理器配置 + */ +export interface AuthManagerConfig { + /** 会话TTL(秒) */ + sessionTTL?: number; + /** 记住我最大存活时间(秒) */ + rememberMeMaxAge?: number; + /** 页面配置 */ + pageConfig?: { + title?: string; + brandName?: string; + logoUrl?: string; + }; +} + +/** + * 认证管理器 + * 专门处理密码认证 + */ +export class AuthManager { + private readonly passwordAuth: PasswordAuthStrategy; + + constructor( + oidcConfig: OIDCProviderConfig, + passwordValidator: PasswordValidator, + config: AuthManagerConfig = {} + ) { + const passwordConfig: PasswordAuthConfig = { + sessionTTL: config.sessionTTL || 24 * 60 * 60, // 默认24小时 + rememberMeMaxAge: config.rememberMeMaxAge || 30 * 24 * 60 * 60, // 默认30天 + pageConfig: config.pageConfig || {} + }; + + this.passwordAuth = new PasswordAuthStrategy( + oidcConfig, + passwordValidator, + passwordConfig + ); + } + + /** + * 检查当前用户是否已认证 + */ + async getCurrentUser(c: Context): Promise { + return await this.passwordAuth.getCurrentUser(c); + } + + /** + * 处理认证要求 + */ + async handleAuthenticationRequired( + c: Context, + authRequest: AuthorizationRequest + ): Promise { + return await this.passwordAuth.handleAuthenticationRequired(c, authRequest); + } + + /** + * 处理认证请求 + */ + async authenticate(c: Context): Promise { + return await this.passwordAuth.authenticate(c); + } + + /** + * 处理登出请求 + */ + async logout(c: Context): Promise { + return await this.passwordAuth.logout(c); + } + + /** + * 处理完整的登录流程 + */ + async handleLogin(c: Context, authRequest: AuthorizationRequest): Promise { + try { + // 执行认证 + const authResult = await this.passwordAuth.authenticate(c); + + if (authResult.success) { + // 认证成功,处理后续操作 + return await this.handleAuthenticationSuccess(c, authResult); + } else { + // 认证失败,返回错误页面 + return await this.handleAuthenticationFailure(c, authResult, authRequest); + } + } catch (error) { + console.error('登录流程处理失败:', error); + + // 返回通用错误页面 + return await this.handleAuthenticationFailure( + c, + { success: false, error: '服务器内部错误' }, + authRequest + ); + } + } + + /** + * 检查认证状态并处理 + */ + async checkAuthenticationStatus( + c: Context, + authRequest: AuthorizationRequest + ): Promise<{ authenticated: boolean; userId?: string; response?: Response }> { + try { + const userId = await this.getCurrentUser(c); + + if (userId) { + return { + authenticated: true, + userId + }; + } else { + const response = await this.handleAuthenticationRequired(c, authRequest); + return { + authenticated: false, + response + }; + } + } catch (error) { + console.error('检查认证状态失败:', error); + throw error; + } + } + + /** + * 处理认证成功 + */ + private async handleAuthenticationSuccess( + c: Context, + result: AuthenticationResult + ): Promise { + return await this.passwordAuth.handleAuthenticationSuccess(c, result); + } + + /** + * 处理认证失败 + */ + private async handleAuthenticationFailure( + c: Context, + result: AuthenticationResult, + authRequest: AuthorizationRequest + ): Promise { + return await this.passwordAuth.handleAuthenticationFailure(c, result, authRequest); + } +} \ No newline at end of file diff --git a/packages/oidc-provider/src/auth/index.ts b/packages/oidc-provider/src/auth/index.ts new file mode 100644 index 0000000..4220fce --- /dev/null +++ b/packages/oidc-provider/src/auth/index.ts @@ -0,0 +1,13 @@ +// 核心管理器 +export { AuthManager, type AuthManagerConfig, type PasswordValidator } from './auth-manager'; + +// 密码认证 +export { PasswordAuthStrategy, type AuthenticationResult, type PasswordAuthConfig } from './strategies/password-auth-strategy'; + +// 工具类 +export { CookieUtils, type CookieConfig } from './utils/cookie-utils'; +export { HtmlTemplates, type PageConfig } from './utils/html-templates'; + +// 原有组件(向后兼容) +export { TokenManager } from './token-manager'; +export { SessionManager } from './session-manager'; \ No newline at end of file diff --git a/packages/oidc-provider/src/auth/session-manager.ts b/packages/oidc-provider/src/auth/session-manager.ts new file mode 100644 index 0000000..0baedfb --- /dev/null +++ b/packages/oidc-provider/src/auth/session-manager.ts @@ -0,0 +1,83 @@ +import type { StorageAdapter } from '../storage'; +import type { AuthorizationRequest } from '../types'; + +/** + * 会话数据接口 + */ +interface SessionData { + session_id: string; + user_id: string; + client_id: string; + created_at: number; + auth_params?: AuthorizationRequest; +} + +/** + * 会话管理器 + * 处理用户会话的存储业务逻辑 + */ +export class SessionManager { + constructor(private storage: StorageAdapter, private sessionTTL: number = 3600) { } + + private getSessionKey(sessionId: string): string { + return `session:${sessionId}`; + } + + /** + * 生成唯一的会话ID + */ + private generateSessionId(): string { + return Math.random().toString(36).substring(2) + Date.now().toString(36); + } + + /** + * 创建新会话 + */ + async createSession( + userId: string, + clientId: string, + authParams?: AuthorizationRequest + ): Promise { + const sessionId = this.generateSessionId(); + const sessionData: SessionData = { + session_id: sessionId, + user_id: userId, + client_id: clientId, + created_at: Date.now(), + auth_params: authParams, + }; + + await this.storeSession(sessionId, sessionData); + return sessionData; + } + + /** + * 销毁会话 + */ + async destroySession(sessionId: string): Promise { + await this.deleteSession(sessionId); + } + + async storeSession(sessionId: string, data: any): Promise { + const key = this.getSessionKey(sessionId); + await this.storage.set(key, data, this.sessionTTL); + } + + async getSession(sessionId: string): Promise { + const key = this.getSessionKey(sessionId); + return await this.storage.get(key); + } + + async deleteSession(sessionId: string): Promise { + const key = this.getSessionKey(sessionId); + await this.storage.delete(key); + } + + async updateSession(sessionId: string, updates: any): Promise { + const existingSession = await this.getSession(sessionId); + if (existingSession) { + const updatedSession = { ...existingSession, ...updates }; + await this.storeSession(sessionId, updatedSession); + } + } +} \ No newline at end of file diff --git a/packages/oidc-provider/src/auth/strategies/password-auth-strategy.ts b/packages/oidc-provider/src/auth/strategies/password-auth-strategy.ts new file mode 100644 index 0000000..036bf53 --- /dev/null +++ b/packages/oidc-provider/src/auth/strategies/password-auth-strategy.ts @@ -0,0 +1,304 @@ +import type { Context } from 'hono'; +import type { AuthorizationRequest, LoginCredentials, PasswordValidator, OIDCProviderConfig } from '../../types'; +import { SessionManager } from '../session-manager'; +import { CookieUtils } from '../utils/cookie-utils'; +import { HtmlTemplates, type PageConfig } from '../utils/html-templates'; + +/** + * 认证结果接口 + */ +export interface AuthenticationResult { + /** 是否认证成功 */ + success: boolean; + /** 用户ID(认证成功时) */ + userId?: string; + /** 错误信息(认证失败时) */ + error?: string; + /** 附加数据 */ + metadata?: Record; +} + +/** + * 密码认证策略配置 + */ +export interface PasswordAuthConfig { + /** 会话TTL(秒) */ + sessionTTL?: number; + /** 页面配置 */ + pageConfig?: PageConfig; + /** 记住我功能的最大时长(秒) */ + rememberMeMaxAge?: number; +} + +/** + * 密码认证服务 + * 实现基于用户名和密码的传统认证方式 + */ +export class PasswordAuthStrategy { + readonly name = 'password'; + + private readonly sessionManager: SessionManager; + private readonly config: PasswordAuthConfig; + + constructor( + private readonly oidcConfig: OIDCProviderConfig, + private readonly passwordValidator: PasswordValidator, + config: PasswordAuthConfig = {} + ) { + this.config = { + sessionTTL: 24 * 60 * 60, // 默认24小时 + rememberMeMaxAge: 30 * 24 * 60 * 60, // 默认30天 + ...config + }; + this.sessionManager = new SessionManager(oidcConfig.storage, this.config.sessionTTL); + } + + /** + * 检查当前用户是否已认证 + */ + async getCurrentUser(c: Context): Promise { + const sessionId = CookieUtils.getSessionIdFromCookie(c); + if (!sessionId) { + return null; + } + + try { + const session = await this.sessionManager.getSession(sessionId); + return session?.user_id || null; + } catch (error) { + console.error('获取会话失败:', error); + return null; + } + } + + /** + * 处理认证要求 - 显示登录页面 + */ + async handleAuthenticationRequired(c: Context, authRequest: AuthorizationRequest): Promise { + const loginHtml = HtmlTemplates.generateLoginPage(authRequest, this.config.pageConfig); + + c.header('Content-Type', 'text/html; charset=utf-8'); + return c.body(loginHtml); + } + + /** + * 处理认证请求 + */ + async authenticate(c: Context): Promise { + try { + // 解析表单数据 + const formData = await c.req.formData(); + const credentials = this.parseCredentials(formData); + const authParams = this.extractAuthParams(formData); + + // 验证输入 + const validationError = this.validateCredentials(credentials); + if (validationError) { + return { + success: false, + error: validationError + }; + } + + // 验证用户密码 + const userId = await this.passwordValidator(credentials.username, credentials.password); + if (!userId) { + return { + success: false, + error: '用户名或密码错误' + }; + } + + // 检查用户是否存在 + const user = await this.oidcConfig.findUser(userId); + if (!user) { + return { + success: false, + error: '用户不存在' + }; + } + + // 创建会话 + const session = await this.sessionManager.createSession( + userId, + authParams.client_id, + authParams + ); + + // 设置Cookie + const maxAge = credentials.remember_me + ? this.config.rememberMeMaxAge! + : this.config.sessionTTL!; + + return { + success: true, + userId, + metadata: { + sessionId: session.session_id, + maxAge, + authParams, + rememberMe: credentials.remember_me + } + }; + + } catch (error) { + console.error('认证处理失败:', error); + return { + success: false, + error: '服务器内部错误,请稍后重试' + }; + } + } + + /** + * 处理登出请求 + */ + async logout(c: Context): Promise { + const sessionId = CookieUtils.getSessionIdFromCookie(c); + + if (sessionId) { + try { + await this.sessionManager.destroySession(sessionId); + } catch (error) { + console.error('销毁会话失败:', error); + } + } + + // 清除Cookie + CookieUtils.clearSessionCookie(c); + + // 处理重定向 + const postLogoutRedirectUri = c.req.query('post_logout_redirect_uri'); + const state = c.req.query('state'); + + if (postLogoutRedirectUri) { + const redirectUrl = this.buildPostLogoutRedirectUrl(postLogoutRedirectUri, state); + return c.redirect(redirectUrl); + } + + return c.json({ + message: '已成功登出', + timestamp: new Date().toISOString() + }); + } + + /** + * 处理认证成功后的操作 + */ + async handleAuthenticationSuccess( + c: Context, + result: AuthenticationResult + ): Promise { + if (!result.success || !result.metadata) { + throw new Error('认证结果无效'); + } + + const { sessionId, maxAge, authParams } = result.metadata; + + // 设置会话Cookie + CookieUtils.setSessionCookie(c, sessionId, maxAge); + + // 重定向到授权端点 + const authUrl = this.buildAuthorizationUrl(authParams); + return c.redirect(authUrl); + } + + /** + * 处理认证失败后的操作 + */ + async handleAuthenticationFailure( + c: Context, + result: AuthenticationResult, + authRequest: AuthorizationRequest + ): Promise { + const errorHtml = HtmlTemplates.generateLoginPage( + authRequest, + this.config.pageConfig, + result.error + ); + + c.header('Content-Type', 'text/html; charset=utf-8'); + return c.body(errorHtml); + } + + /** + * 解析登录凭据 + */ + private parseCredentials(formData: FormData): LoginCredentials { + return { + username: formData.get('username')?.toString() || '', + password: formData.get('password')?.toString() || '', + remember_me: formData.get('remember_me') === 'on', + }; + } + + /** + * 验证登录凭据 + */ + private validateCredentials(credentials: LoginCredentials): string | null { + if (!credentials.username.trim()) { + return '请输入用户名'; + } + + if (!credentials.password) { + return '请输入密码'; + } + + if (credentials.username.length > 100) { + return '用户名过长'; + } + + if (credentials.password.length > 200) { + return '密码过长'; + } + + return null; + } + + /** + * 构建登出后重定向URL + */ + private buildPostLogoutRedirectUrl(redirectUri: string, state?: string): string { + if (!state) { + return redirectUri; + } + + const url = new URL(redirectUri); + url.searchParams.set('state', state); + return url.toString(); + } + + /** + * 构建授权URL + */ + protected buildAuthorizationUrl(authParams: AuthorizationRequest): string { + const params = new URLSearchParams(); + params.set('response_type', authParams.response_type); + params.set('client_id', authParams.client_id); + params.set('redirect_uri', authParams.redirect_uri); + params.set('scope', authParams.scope); + + if (authParams.state) params.set('state', authParams.state); + if (authParams.nonce) params.set('nonce', authParams.nonce); + if (authParams.code_challenge) params.set('code_challenge', authParams.code_challenge); + if (authParams.code_challenge_method) params.set('code_challenge_method', authParams.code_challenge_method); + + return `/oidc/auth?${params.toString()}`; + } + + /** + * 从表单数据提取授权参数 + */ + protected extractAuthParams(formData: FormData): AuthorizationRequest { + return { + response_type: formData.get('response_type')?.toString() || '', + client_id: formData.get('client_id')?.toString() || '', + redirect_uri: formData.get('redirect_uri')?.toString() || '', + scope: formData.get('scope')?.toString() || '', + state: formData.get('state')?.toString(), + nonce: formData.get('nonce')?.toString(), + code_challenge: formData.get('code_challenge')?.toString(), + code_challenge_method: formData.get('code_challenge_method')?.toString() as 'plain' | 'S256' | undefined, + }; + } +} \ No newline at end of file diff --git a/packages/oidc-provider/src/auth/token-manager.ts b/packages/oidc-provider/src/auth/token-manager.ts new file mode 100644 index 0000000..0e18d26 --- /dev/null +++ b/packages/oidc-provider/src/auth/token-manager.ts @@ -0,0 +1,205 @@ +import type { + AuthorizationCode, + AccessToken, + RefreshToken, + IDToken, +} from '../types'; +import type { StorageAdapter } from '../storage'; + +/** + * 令牌管理器 + * 处理所有令牌相关的存储业务逻辑 + */ +export class TokenManager { + constructor(private storage: StorageAdapter) { } + + // 生成存储键 + private getTokenKey(type: string, token: string): string { + return `${type}:${token}`; + } + + private getUserClientKey(type: string, userId: string, clientId: string): string { + return `${type}:user:${userId}:client:${clientId}`; + } + + // 授权码管理 + async storeAuthorizationCode(authCode: AuthorizationCode): Promise { + const key = this.getTokenKey('auth_code', authCode.code); + const ttl = Math.floor((authCode.expires_at.getTime() - Date.now()) / 1000); + + const data = { + ...authCode, + expires_at: authCode.expires_at.toISOString(), + created_at: authCode.created_at.toISOString(), + }; + + await this.storage.set(key, data, Math.max(ttl, 1)); + } + + async getAuthorizationCode(code: string): Promise { + const key = this.getTokenKey('auth_code', code); + const data = await this.storage.get(key); + if (!data) return null; + + return { + ...data, + expires_at: new Date(data.expires_at), + created_at: new Date(data.created_at), + }; + } + + async deleteAuthorizationCode(code: string): Promise { + const key = this.getTokenKey('auth_code', code); + await this.storage.delete(key); + } + + // 访问令牌管理 + async storeAccessToken(token: AccessToken): Promise { + const key = this.getTokenKey('access_token', token.token); + const userClientKey = this.getUserClientKey('access_tokens', token.user_id, token.client_id); + const ttl = Math.floor((token.expires_at.getTime() - Date.now()) / 1000); + + const data = { + ...token, + expires_at: token.expires_at.toISOString(), + created_at: token.created_at.toISOString(), + }; + + // 存储令牌数据 + await this.storage.set(key, data, Math.max(ttl, 1)); + + // 存储用户-客户端索引 + const existingTokens = await this.storage.get(userClientKey) || []; + existingTokens.push(token.token); + await this.storage.set(userClientKey, existingTokens, Math.max(ttl, 1)); + } + + async getAccessToken(token: string): Promise { + const key = this.getTokenKey('access_token', token); + const data = await this.storage.get(key); + if (!data) return null; + + return { + ...data, + expires_at: new Date(data.expires_at), + created_at: new Date(data.created_at), + }; + } + + async deleteAccessToken(token: string): Promise { + const key = this.getTokenKey('access_token', token); + + // 获取令牌数据以清理索引 + const tokenData = await this.storage.get(key); + if (tokenData) { + const userClientKey = this.getUserClientKey('access_tokens', tokenData.user_id, tokenData.client_id); + const tokens = await this.storage.get(userClientKey) || []; + const filteredTokens = tokens.filter(t => t !== token); + + if (filteredTokens.length > 0) { + await this.storage.set(userClientKey, filteredTokens); + } else { + await this.storage.delete(userClientKey); + } + } + + await this.storage.delete(key); + } + + async deleteAccessTokensByUserAndClient(userId: string, clientId: string): Promise { + const userClientKey = this.getUserClientKey('access_tokens', userId, clientId); + const tokens = await this.storage.get(userClientKey) || []; + + // 删除所有相关令牌 + for (const token of tokens) { + await this.storage.delete(this.getTokenKey('access_token', token)); + } + + // 删除索引 + await this.storage.delete(userClientKey); + } + + // 刷新令牌管理 + async storeRefreshToken(token: RefreshToken): Promise { + const key = this.getTokenKey('refresh_token', token.token); + const userClientKey = this.getUserClientKey('refresh_tokens', token.user_id, token.client_id); + const ttl = Math.floor((token.expires_at.getTime() - Date.now()) / 1000); + + const data = { + ...token, + expires_at: token.expires_at.toISOString(), + created_at: token.created_at.toISOString(), + }; + + // 存储令牌数据 + await this.storage.set(key, data, Math.max(ttl, 1)); + + // 存储用户-客户端索引 + const existingTokens = await this.storage.get(userClientKey) || []; + existingTokens.push(token.token); + await this.storage.set(userClientKey, existingTokens, Math.max(ttl, 1)); + } + + async getRefreshToken(token: string): Promise { + const key = this.getTokenKey('refresh_token', token); + const data = await this.storage.get(key); + if (!data) return null; + + return { + ...data, + expires_at: new Date(data.expires_at), + created_at: new Date(data.created_at), + }; + } + + async deleteRefreshToken(token: string): Promise { + const key = this.getTokenKey('refresh_token', token); + + // 获取令牌数据以清理索引 + const tokenData = await this.storage.get(key); + if (tokenData) { + const userClientKey = this.getUserClientKey('refresh_tokens', tokenData.user_id, tokenData.client_id); + const tokens = await this.storage.get(userClientKey) || []; + const filteredTokens = tokens.filter(t => t !== token); + + if (filteredTokens.length > 0) { + await this.storage.set(userClientKey, filteredTokens); + } else { + await this.storage.delete(userClientKey); + } + } + + await this.storage.delete(key); + } + + // ID令牌管理 + async storeIDToken(token: IDToken): Promise { + const key = this.getTokenKey('id_token', token.token); + const ttl = Math.floor((token.expires_at.getTime() - Date.now()) / 1000); + + const data = { + ...token, + expires_at: token.expires_at.toISOString(), + created_at: token.created_at.toISOString(), + }; + + await this.storage.set(key, data, Math.max(ttl, 1)); + } + + async getIDToken(token: string): Promise { + const key = this.getTokenKey('id_token', token); + const data = await this.storage.get(key); + if (!data) return null; + + return { + ...data, + expires_at: new Date(data.expires_at), + created_at: new Date(data.created_at), + }; + } + + async deleteIDToken(token: string): Promise { + const key = this.getTokenKey('id_token', token); + await this.storage.delete(key); + } +} \ No newline at end of file diff --git a/packages/oidc-provider/src/auth/utils/cookie-utils.ts b/packages/oidc-provider/src/auth/utils/cookie-utils.ts new file mode 100644 index 0000000..18a754b --- /dev/null +++ b/packages/oidc-provider/src/auth/utils/cookie-utils.ts @@ -0,0 +1,121 @@ +import type { Context } from 'hono'; + +/** + * Cookie配置接口 + */ +export interface CookieConfig { + /** Cookie名称 */ + name: string; + /** 过期时间(秒) */ + maxAge?: number; + /** 路径 */ + path?: string; + /** 域名 */ + domain?: string; + /** 是否安全连接 */ + secure?: boolean; + /** HttpOnly标志 */ + httpOnly?: boolean; + /** SameSite属性 */ + sameSite?: 'Strict' | 'Lax' | 'None'; +} + +/** + * Cookie工具类 + */ +export class CookieUtils { + private static readonly DEFAULT_SESSION_COOKIE_NAME = 'oidc-session'; + private static readonly DEFAULT_MAX_AGE = 24 * 60 * 60; // 24小时 + + /** + * 设置Cookie + */ + static setCookie(c: Context, value: string, config: Partial = {}): void { + const { + name = this.DEFAULT_SESSION_COOKIE_NAME, + maxAge = this.DEFAULT_MAX_AGE, + path = '/', + secure = process.env.NODE_ENV === 'production', + httpOnly = true, + sameSite = process.env.NODE_ENV === 'production' ? 'None' : 'Lax' + } = config; + + const cookieParts = [ + `${name}=${encodeURIComponent(value)}`, + `Path=${path}`, + `Max-Age=${maxAge}` + ]; + + if (httpOnly) cookieParts.push('HttpOnly'); + if (secure) cookieParts.push('Secure'); + cookieParts.push(`SameSite=${sameSite}`); + + c.header('Set-Cookie', cookieParts.join('; ')); + } + + /** + * 设置会话Cookie + */ + static setSessionCookie(c: Context, sessionId: string, maxAge?: number): void { + this.setCookie(c, sessionId, { + name: this.DEFAULT_SESSION_COOKIE_NAME, + maxAge: maxAge || this.DEFAULT_MAX_AGE + }); + } + + /** + * 清除Cookie + */ + static clearCookie(c: Context, name: string = this.DEFAULT_SESSION_COOKIE_NAME): void { + this.setCookie(c, '', { + name, + maxAge: 0 + }); + } + + /** + * 清除会话Cookie + */ + static clearSessionCookie(c: Context): void { + this.clearCookie(c, this.DEFAULT_SESSION_COOKIE_NAME); + } + + /** + * 获取Cookie值 + */ + static getCookie(c: Context, name: string): string | null { + const cookieHeader = c.req.header('Cookie'); + if (!cookieHeader) return null; + + const regex = new RegExp(`(?:^|; )${name}=([^;]*)`); + const match = cookieHeader.match(regex); + + return match && match[1] ? decodeURIComponent(match[1]) : null; + } + + /** + * 获取会话ID从Cookie + */ + static getSessionIdFromCookie(c: Context): string | null { + return this.getCookie(c, this.DEFAULT_SESSION_COOKIE_NAME); + } + + /** + * 解析所有Cookie + */ + static parseAllCookies(c: Context): Record { + const cookieHeader = c.req.header('Cookie'); + if (!cookieHeader) return {}; + + const cookies: Record = {}; + + cookieHeader.split(';').forEach(cookie => { + const [name, ...rest] = cookie.trim().split('='); + if (name && rest.length > 0) { + cookies[name] = decodeURIComponent(rest.join('=')); + } + }); + + return cookies; + } +} \ No newline at end of file diff --git a/packages/oidc-provider/src/auth/utils/html-templates.ts b/packages/oidc-provider/src/auth/utils/html-templates.ts new file mode 100644 index 0000000..4f6f057 --- /dev/null +++ b/packages/oidc-provider/src/auth/utils/html-templates.ts @@ -0,0 +1,341 @@ +import type { AuthorizationRequest } from '../../types'; + +/** + * 页面配置接口 + */ +export interface PageConfig { + /** 页面标题 */ + title?: string; + /** 品牌名称 */ + brandName?: string; + /** 自定义CSS */ + customCSS?: string; + /** 自定义Logo URL */ + logoUrl?: string; +} + +/** + * HTML模板工具类 + */ +export class HtmlTemplates { + private static readonly DEFAULT_TITLE = '用户登录'; + private static readonly DEFAULT_BRAND_NAME = 'OIDC Provider'; + + /** + * 生成登录页面HTML + */ + static generateLoginPage( + authRequest: AuthorizationRequest, + config: PageConfig = {}, + error?: string + ): string { + const { + title = this.DEFAULT_TITLE, + brandName = this.DEFAULT_BRAND_NAME, + customCSS = '', + logoUrl + } = config; + + return ` + + + + + + ${this.escapeHtml(title)} + + + + + +`; + } + + /** + * 生成错误页面HTML + */ + static generateErrorPage( + error: string, + description?: string, + config: PageConfig = {} + ): string { + const { + title = '认证错误', + brandName = this.DEFAULT_BRAND_NAME + } = config; + + return ` + + + + + + ${this.escapeHtml(title)} + + + +
+
⚠️
+

${this.escapeHtml(brandName)}

+

认证失败

+
+ ${this.escapeHtml(error)} + ${description ? `

${this.escapeHtml(description)}

` : ''} +
+ +
+ +`; + } + + /** + * 获取默认样式 + */ + private static getDefaultStyles(): string { + return ` + * { + margin: 0; + padding: 0; + box-sizing: border-box; + } + + body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + } + + .login-container { + background: white; + border-radius: 12px; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1); + overflow: hidden; + max-width: 400px; + width: 100%; + } + + .login-header { + background: #4f46e5; + color: white; + padding: 2rem; + text-align: center; + } + + .logo { + max-width: 60px; + height: auto; + margin-bottom: 1rem; + border-radius: 8px; + } + + .login-header h1 { + font-size: 1.5rem; + margin-bottom: 0.5rem; + } + + .login-header p { + opacity: 0.9; + font-size: 0.875rem; + } + + .login-form { + padding: 2rem; + } + + .form-group { + margin-bottom: 1.5rem; + } + + .form-group label { + display: block; + margin-bottom: 0.5rem; + font-weight: 500; + color: #374151; + } + + .form-group input { + width: 100%; + padding: 0.75rem; + border: 1px solid #d1d5db; + border-radius: 6px; + font-size: 1rem; + transition: border-color 0.2s, box-shadow 0.2s; + } + + .form-group input:focus { + outline: none; + border-color: #4f46e5; + box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1); + } + + .checkbox-group { + display: flex; + align-items: center; + margin-bottom: 1.5rem; + } + + .checkbox-group input { + margin-right: 0.5rem; + } + + .checkbox-group label { + margin: 0; + font-weight: normal; + font-size: 0.875rem; + color: #6b7280; + cursor: pointer; + } + + .login-button { + width: 100%; + background: #4f46e5; + color: white; + border: none; + border-radius: 6px; + padding: 0.75rem; + font-size: 1rem; + font-weight: 500; + cursor: pointer; + transition: background-color 0.2s; + } + + .login-button:hover { + background: #4338ca; + } + + .login-button:active { + background: #3730a3; + } + + .error-message { + background: #fee2e2; + border: 1px solid #fecaca; + color: #dc2626; + padding: 0.75rem; + border-radius: 6px; + margin-bottom: 1.5rem; + font-size: 0.875rem; + } + + .client-info { + background: #f9fafb; + border: 1px solid #e5e7eb; + border-radius: 6px; + padding: 1rem; + margin-bottom: 1.5rem; + } + + .client-info h3 { + font-size: 0.875rem; + font-weight: 600; + color: #374151; + margin-bottom: 0.5rem; + } + + .client-info p { + font-size: 0.75rem; + color: #6b7280; + margin-bottom: 0.25rem; + } + + .scope-info code { + background: #e5e7eb; + padding: 0.125rem 0.25rem; + border-radius: 3px; + font-size: 0.7rem; + } + `; + } + + /** + * 生成隐藏字段 + */ + private static generateHiddenFields(authRequest: AuthorizationRequest): string { + const fields = [ + { name: 'response_type', value: authRequest.response_type }, + { name: 'client_id', value: authRequest.client_id }, + { name: 'redirect_uri', value: authRequest.redirect_uri }, + { name: 'scope', value: authRequest.scope }, + ]; + + // 可选字段 + if (authRequest.state) fields.push({ name: 'state', value: authRequest.state }); + if (authRequest.nonce) fields.push({ name: 'nonce', value: authRequest.nonce }); + if (authRequest.code_challenge) fields.push({ name: 'code_challenge', value: authRequest.code_challenge }); + if (authRequest.code_challenge_method) fields.push({ name: 'code_challenge_method', value: authRequest.code_challenge_method }); + + return fields + .map(field => ``) + .join('\n '); + } + + /** + * 转义HTML字符 + */ + private static escapeHtml(unsafe: string): string { + return unsafe + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } +} \ No newline at end of file diff --git a/packages/oidc-provider/src/index.ts b/packages/oidc-provider/src/index.ts new file mode 100644 index 0000000..db6c9c7 --- /dev/null +++ b/packages/oidc-provider/src/index.ts @@ -0,0 +1,60 @@ +// 核心类 +export { OIDCProvider } from './provider'; + +// 类型定义 +export type { + OIDCProviderConfig, + OIDCClient, + OIDCUser, + AuthorizationCode, + AccessToken, + RefreshToken, + IDToken, + AuthorizationRequest, + TokenRequest, + TokenResponse, + OIDCError, + DiscoveryDocument, + UserSession, + LoginCredentials, + PasswordValidator, +} from './types'; + +// 存储适配器接口和示例实现 +export type { StorageAdapter } from './storage'; +export { RedisStorageAdapter } from './storage'; + +// Hono中间件 +export { + createOIDCProvider, + oidcProvider, + getOIDCProvider +} from './middleware/hono'; + +// 导出中间件配置类型 +export type { + OIDCHonoOptions +} from './middleware/hono'; + +// 工具类 +export { JWTUtils } from './utils/jwt'; +export { PKCEUtils } from './utils/pkce'; +export { ValidationUtils } from './utils/validation'; + +// 认证模块 +export { + AuthManager, + PasswordAuthStrategy, + CookieUtils, + HtmlTemplates, + SessionManager, + TokenManager +} from './auth'; + +export type { + AuthManagerConfig, + PasswordAuthConfig, + AuthenticationResult, + CookieConfig, + PageConfig +} from './auth'; \ No newline at end of file diff --git a/packages/oidc-provider/src/middleware/hono.ts b/packages/oidc-provider/src/middleware/hono.ts new file mode 100644 index 0000000..d4b2293 --- /dev/null +++ b/packages/oidc-provider/src/middleware/hono.ts @@ -0,0 +1,335 @@ +import { Hono } from 'hono'; +import type { Context, Next } from 'hono'; +import { OIDCProvider } from '../provider'; +import type { OIDCProviderConfig, AuthorizationRequest, TokenRequest } from '../types'; +import { AuthManager, type PasswordValidator } from '../auth'; + +/** + * OIDC Provider配置选项 + */ +export interface OIDCHonoOptions { + /** OIDC Provider配置 */ + config: OIDCProviderConfig; + /** 密码验证器 - 用于验证用户名和密码 */ + passwordValidator: PasswordValidator; + /** 认证配置选项 */ + authConfig?: { + /** 会话TTL(秒) */ + sessionTTL?: number; + /** 登录页面标题 */ + loginPageTitle?: string; + /** 品牌名称 */ + brandName?: string; + /** 品牌Logo URL */ + logoUrl?: string; + }; +} + +/** + * 创建OIDC Provider Hono应用 + */ +export function createOIDCProvider(options: OIDCHonoOptions): Hono { + const { config, passwordValidator, authConfig = {} } = options; + + // 创建认证管理器 + const authManager = new AuthManager( + config, + passwordValidator, + { + sessionTTL: authConfig.sessionTTL, + pageConfig: { + title: authConfig.loginPageTitle, + brandName: authConfig.brandName, + logoUrl: authConfig.logoUrl + } + } + ); + + const app = new Hono(); + const provider = new OIDCProvider(config); + + // 登录端点 + app.post('/login', async (c: Context) => { + // 从表单中提取授权参数 + const formData = await c.req.formData(); + const authRequest = { + response_type: formData.get('response_type')?.toString() || '', + client_id: formData.get('client_id')?.toString() || '', + redirect_uri: formData.get('redirect_uri')?.toString() || '', + scope: formData.get('scope')?.toString() || '', + state: formData.get('state')?.toString(), + nonce: formData.get('nonce')?.toString(), + code_challenge: formData.get('code_challenge')?.toString(), + code_challenge_method: formData.get('code_challenge_method')?.toString() as 'plain' | 'S256' | undefined, + }; + + return await authManager.handleLogin(c, authRequest); + }); + + // 登出端点 + app.get('/logout', async (c: Context) => { + return await authManager.logout(c); + }); + + app.post('/logout', async (c: Context) => { + return await authManager.logout(c); + }); + + // 发现文档端点 + app.get('/.well-known/openid-configuration', async (c: Context) => { + const discovery = provider.getDiscoveryDocument(); + return c.json(discovery); + }); + + // JWKS端点 + app.get('/.well-known/jwks.json', async (c: Context) => { + const jwks = await provider.getJWKS(); + return c.json(jwks); + }); + + // 授权端点 - 使用认证管理器 + app.get('/auth', async (c: Context) => { + const query = c.req.query(); + + const authRequest: AuthorizationRequest = { + 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) : undefined, + id_token_hint: query.id_token_hint, + login_hint: query.login_hint, + acr_values: query.acr_values, + }; + + // 检查用户认证状态 + const userId = await authManager.getCurrentUser(c); + if (!userId) { + // 用户未认证,显示登录页面 + return await authManager.handleAuthenticationRequired(c, authRequest); + } + + // 用户已认证,处理授权请求 + const result = await provider.handleAuthorizationRequest(authRequest, userId); + + if (!result.success) { + const error = result.error; + const errorParams = new URLSearchParams({ + error: error.error, + ...(error.error_description && { error_description: error.error_description }), + ...(error.error_uri && { error_uri: error.error_uri }), + ...(error.state && { state: error.state }), + }); + + const redirectUri = result.redirectUri || query.redirect_uri; + if (redirectUri) { + return c.redirect(`${redirectUri}?${errorParams.toString()}`); + } else { + c.status(400); + return c.json({ + error: error.error, + error_description: error.error_description, + }); + } + } + + // 成功生成授权码,重定向回客户端 + const params = new URLSearchParams({ + code: result.code, + ...(query.state && { state: query.state }), + }); + + return c.redirect(`${result.redirectUri}?${params.toString()}`); + }); + + // 令牌端点 + app.post('/token', async (c: Context) => { + const body = await c.req.formData(); + // 将可选字段的类型处理为可选的而不是undefined + const clientId = body.get('client_id')?.toString(); + const tokenRequest: TokenRequest = { + grant_type: body.get('grant_type')?.toString() || '', + client_id: clientId || '', + }; + // 可选参数,只有存在时才添加 + const code = body.get('code')?.toString(); + if (code) tokenRequest.code = code; + + const redirectUri = body.get('redirect_uri')?.toString(); + if (redirectUri) tokenRequest.redirect_uri = redirectUri; + + const clientSecret = body.get('client_secret')?.toString(); + if (clientSecret) tokenRequest.client_secret = clientSecret; + + const refreshToken = body.get('refresh_token')?.toString(); + if (refreshToken) tokenRequest.refresh_token = refreshToken; + + const codeVerifier = body.get('code_verifier')?.toString(); + if (codeVerifier) tokenRequest.code_verifier = codeVerifier; + + const scope = body.get('scope')?.toString(); + if (scope) tokenRequest.scope = scope; + + // 客户端认证 + const authHeader = c.req.header('Authorization'); + if (authHeader?.startsWith('Basic ')) { + const decoded = atob(authHeader.substring(6)); + const [headerClientId, headerClientSecret] = decoded.split(':'); + if (headerClientId) { + tokenRequest.client_id = headerClientId; + } + if (headerClientSecret) { + tokenRequest.client_secret = headerClientSecret; + } + } + + // 请求令牌 + const result = await provider.handleTokenRequest(tokenRequest); + + if (!result.success) { + c.status(400); + return c.json({ + error: result.error.error, + error_description: result.error.error_description, + }); + } + + return c.json(result.response); + }); + + // 用户信息端点 + app.get('/userinfo', async (c: Context) => { + const authHeader = c.req.header('Authorization'); + if (!authHeader || !authHeader.startsWith('Bearer ')) { + c.status(401); + c.header('WWW-Authenticate', 'Bearer'); + return c.json({ + error: 'invalid_token', + error_description: '无效的访问令牌', + }); + } + + const accessToken = authHeader.substring(7); + const result = await provider.getUserInfo(accessToken); + + if (!result.success) { + c.status(401); + c.header('WWW-Authenticate', `Bearer error="${result.error.error}"`); + return c.json({ + error: result.error.error, + error_description: result.error.error_description, + }); + } + + return c.json(result.user); + }); + + // 令牌撤销端点 + app.post('/revoke', async (c: Context) => { + const body = await c.req.formData(); + + const token = body.get('token')?.toString() || ''; + const tokenTypeHint = body.get('token_type_hint')?.toString(); + const clientId = body.get('client_id')?.toString(); + const clientSecret = body.get('client_secret')?.toString(); + + if (!token) { + c.status(400); + return c.json({ + error: 'invalid_request', + error_description: '缺少token参数', + }); + } + + // 客户端认证 + let authClientId = clientId; + let authClientSecret = clientSecret; + + const authHeader = c.req.header('Authorization'); + if (authHeader?.startsWith('Basic ')) { + const decoded = atob(authHeader.substring(6)); + const [id, secret] = decoded.split(':'); + authClientId = id; + authClientSecret = secret; + } + + // 撤销令牌 + const result = await provider.revokeToken(token, tokenTypeHint); + + if (!result.success && result.error) { + c.status(400); + return c.json({ + error: result.error.error, + error_description: result.error.error_description, + }); + } + + // 撤销成功 + c.status(200); + return c.body(null); + }); + + // 令牌内省端点 + app.post('/introspect', async (c: Context) => { + const body = await c.req.formData(); + + const token = body.get('token')?.toString() || ''; + const tokenTypeHint = body.get('token_type_hint')?.toString(); + const clientId = body.get('client_id')?.toString(); + const clientSecret = body.get('client_secret')?.toString(); + + if (!token) { + c.status(400); + return c.json({ + error: 'invalid_request', + error_description: '缺少token参数', + }); + } + + // 客户端认证 + let authClientId = clientId; + let authClientSecret = clientSecret; + + const authHeader = c.req.header('Authorization'); + if (authHeader?.startsWith('Basic ')) { + const decoded = atob(authHeader.substring(6)); + const [id, secret] = decoded.split(':'); + authClientId = id; + authClientSecret = secret; + } + + // 内省令牌 + const result = await provider.introspectToken(token); + + // 返回内省结果 + return c.json(result); + }); + + // 返回应用实例 + return app; +} + +/** + * OIDC Provider中间件 + */ +export function oidcProvider(config: OIDCProviderConfig) { + const provider = new OIDCProvider(config); + + return (c: Context, next: Next) => { + c.set('oidc:provider', provider); + return next(); + }; +} + +/** + * 获取OIDC Provider实例 + */ +export function getOIDCProvider(c: Context): OIDCProvider { + return c.get('oidc:provider'); +} + diff --git a/packages/oidc-provider/src/provider.ts b/packages/oidc-provider/src/provider.ts new file mode 100644 index 0000000..dfc99cf --- /dev/null +++ b/packages/oidc-provider/src/provider.ts @@ -0,0 +1,932 @@ +import { nanoid } from 'nanoid'; +import type { + OIDCProviderConfig, + OIDCClient, + OIDCUser, + AuthorizationCode, + AccessToken, + RefreshToken, + IDToken, + AuthorizationRequest, + TokenRequest, + TokenResponse, + OIDCError, + DiscoveryDocument, +} from './types'; +import type { StorageAdapter } from './storage/adapter'; +import { TokenManager } from './auth/token-manager'; +import { JWTUtils } from './utils/jwt'; +import { PKCEUtils } from './utils/pkce'; +import { ValidationUtils } from './utils/validation'; + +/** + * OIDC Provider核心类 + */ +export class OIDCProvider { + private 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 storage: StorageAdapter; + private tokenManager: TokenManager; + private jwtUtils: JWTUtils; + private findUser: (userId: string) => Promise; + private findClient: (clientId: string) => Promise; + + constructor(config: OIDCProviderConfig) { + // 设置默认配置 + const defaultTokenTTL = { + accessToken: 3600, // 1小时 + refreshToken: 30 * 24 * 3600, // 30天 + authorizationCode: 600, // 10分钟 + idToken: 3600, // 1小时 + }; + + this.config = { + ...config, + tokenTTL: { + ...defaultTokenTTL, + ...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, + }; + + 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); + } + + /** + * 获取发现文档 + */ + getDiscoveryDocument(): DiscoveryDocument { + const baseUrl = this.config.issuer; + + 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, + token_endpoint_auth_methods_supported: ['client_secret_basic', 'client_secret_post', 'none'], + id_token_signing_alg_values_supported: ['HS256', 'RS256', 'ES256'], + 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, + }; + } + + /** + * 处理授权请求 + */ + async handleAuthorizationRequest( + request: Partial, + userId?: string + ): Promise<{ success: true; code: string; redirectUri: string } | { success: false; error: OIDCError; redirectUri?: string }> { + try { + // 获取客户端信息 + if (!request.client_id) { + return { + success: false, + error: { + error: 'invalid_request', + error_description: 'Missing client_id parameter', + state: request.state, + }, + }; + } + + 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, + }, + }; + } + + // 验证请求 + 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, + }; + } + + // 强制PKCE检查 + if (this.config.requirePKCE && client.client_type === 'public' && !request.code_challenge) { + return { + success: false, + error: { + error: 'invalid_request', + error_description: 'PKCE is required for public clients', + state: request.state, + }, + redirectUri: request.redirect_uri, + }; + } + + // 验证PKCE(如果启用且提供了代码挑战) + if (this.config.enablePKCE && request.code_challenge) { + if (!request.code_challenge_method) { + return { + success: false, + error: { + error: 'invalid_request', + error_description: 'Missing code_challenge_method', + state: request.state, + }, + redirectUri: request.redirect_uri, + }; + } + + if (!PKCEUtils.isSupportedMethod(request.code_challenge_method)) { + return { + success: false, + error: { + error: 'invalid_request', + error_description: 'Unsupported code_challenge_method', + state: request.state, + }, + redirectUri: request.redirect_uri, + }; + } + + if (!PKCEUtils.isValidCodeChallenge(request.code_challenge, request.code_challenge_method)) { + return { + success: false, + error: { + error: 'invalid_request', + error_description: 'Invalid code_challenge', + state: request.state, + }, + redirectUri: request.redirect_uri, + }; + } + } + + // 生成授权码 + const code = nanoid(32); + const now = new Date(); + const expiresAt = new Date(now.getTime() + this.config.tokenTTL.authorizationCode * 1000); + + const authCode: AuthorizationCode = { + code, + 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: expiresAt, + created_at: now, + }; + + await this.tokenManager.storeAuthorizationCode(authCode); + + return { + success: true, + code, + redirectUri: request.redirect_uri!, + }; + } catch (error) { + return { + success: false, + error: { + error: 'server_error', + error_description: 'Internal server error', + state: request.state, + }, + redirectUri: request.redirect_uri, + }; + } + } + + /** + * 处理令牌请求 + */ + async handleTokenRequest( + request: Partial + ): Promise<{ success: true; response: TokenResponse } | { success: false; error: OIDCError }> { + try { + // 获取客户端信息 + if (!request.client_id) { + return { + success: false, + error: { + error: 'invalid_request', + error_description: 'Missing client_id parameter', + }, + }; + } + + const client = await this.findClient(request.client_id); + if (!client) { + return { + success: false, + error: { + error: 'invalid_client', + error_description: 'Invalid client_id', + }, + }; + } + + // 验证客户端认证(如果需要) + if (client.client_type === 'confidential') { + if (!request.client_secret) { + return { + success: false, + error: { + error: 'invalid_client', + error_description: 'Client authentication required', + }, + }; + } + + if (request.client_secret !== client.client_secret) { + return { + success: false, + error: { + error: 'invalid_client', + error_description: 'Invalid client credentials', + }, + }; + } + } + + // 验证请求 + const validation = ValidationUtils.validateTokenRequest( + request, + client, + this.config.grantTypes + ); + + if (!validation.valid) { + return { + success: false, + error: { + error: 'invalid_request', + error_description: validation.errors.join(', '), + }, + }; + } + + 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 { + success: false, + error: { + error: 'unsupported_grant_type', + error_description: 'Grant type not supported', + }, + }; + } + } catch (error) { + return { + success: false, + error: { + error: 'server_error', + error_description: 'Internal server error', + }, + }; + } + } + + /** + * 处理授权码授权类型 + */ + private async handleAuthorizationCodeGrant( + request: Partial, + client: OIDCClient + ): Promise<{ success: true; response: TokenResponse } | { success: false; error: OIDCError }> { + if (!request.code) { + return { + success: false, + error: { + error: 'invalid_request', + error_description: 'Missing authorization code', + }, + }; + } + + // 获取授权码 + const authCode = await this.tokenManager.getAuthorizationCode(request.code); + if (!authCode) { + return { + success: false, + error: { + error: 'invalid_grant', + error_description: 'Invalid authorization code', + }, + }; + } + + // 检查授权码是否过期 + if (authCode.expires_at < new Date()) { + await this.tokenManager.deleteAuthorizationCode(request.code); + return { + success: false, + error: { + error: 'invalid_grant', + error_description: 'Authorization code expired', + }, + }; + } + + // 验证客户端ID + if (authCode.client_id !== request.client_id) { + return { + success: false, + error: { + error: 'invalid_grant', + error_description: 'Authorization code was not issued to this client', + }, + }; + } + + // 验证重定向URI + if (authCode.redirect_uri !== request.redirect_uri) { + return { + success: false, + error: { + error: 'invalid_grant', + error_description: 'Redirect URI mismatch', + }, + }; + } + + // 验证PKCE(如果使用) + if (authCode.code_challenge) { + if (!request.code_verifier) { + return { + success: false, + error: { + error: 'invalid_request', + error_description: 'Missing code_verifier', + }, + }; + } + + const isValidPKCE = PKCEUtils.verifyCodeChallenge( + request.code_verifier, + authCode.code_challenge, + authCode.code_challenge_method || 'plain' + ); + + if (!isValidPKCE) { + return { + success: false, + error: { + error: 'invalid_grant', + error_description: 'Invalid code_verifier', + }, + }; + } + } + + // 获取用户信息 + const user = await this.findUser(authCode.user_id); + if (!user) { + return { + success: false, + error: { + error: 'invalid_grant', + error_description: 'User not found', + }, + }; + } + + // 生成令牌 + const now = new Date(); + 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); + + // 生成访问令牌 + const accessTokenJWT = await this.jwtUtils.generateAccessToken({ + issuer: this.config.issuer, + subject: user.sub, + audience: request.client_id!, + clientId: request.client_id!, + scope: authCode.scope, + expiresIn: this.config.tokenTTL.accessToken, + }); + + const accessToken: AccessToken = { + token: accessTokenJWT, + client_id: request.client_id!, + user_id: user.sub, + scope: authCode.scope, + expires_at: accessTokenExpiry, + created_at: now, + }; + + // 生成刷新令牌 + const refreshTokenValue = nanoid(64); + const refreshToken: RefreshToken = { + token: refreshTokenValue, + client_id: request.client_id!, + user_id: user.sub, + scope: authCode.scope, + expires_at: refreshTokenExpiry, + created_at: now, + }; + + // 生成ID令牌(如果请求了openid作用域) + let idTokenJWT: string | undefined; + if (authCode.scope.includes('openid')) { + const requestedClaims = this.getRequestedClaims(authCode.scope); + + idTokenJWT = await this.jwtUtils.generateIDToken({ + issuer: this.config.issuer, + subject: user.sub, + audience: request.client_id!, + user, + authTime: Math.floor(now.getTime() / 1000), + nonce: authCode.nonce, + expiresIn: this.config.tokenTTL.idToken, + requestedClaims, + }); + + const idToken: IDToken = { + token: idTokenJWT, + client_id: request.client_id!, + user_id: user.sub, + nonce: authCode.nonce, + expires_at: idTokenExpiry, + created_at: now, + }; + + await this.tokenManager.storeIDToken(idToken); + } + + // 存储令牌 + await this.tokenManager.storeAccessToken(accessToken); + await this.tokenManager.storeRefreshToken(refreshToken); + + // 删除已使用的授权码 + await this.tokenManager.deleteAuthorizationCode(request.code); + + const response: TokenResponse = { + access_token: accessTokenJWT, + token_type: 'Bearer', + expires_in: this.config.tokenTTL.accessToken, + refresh_token: refreshTokenValue, + scope: authCode.scope, + }; + + if (idTokenJWT) { + response.id_token = idTokenJWT; + } + + return { + success: true, + response, + }; + } + + /** + * 处理刷新令牌授权类型 - 修复以符合OIDC规范 + */ + private async handleRefreshTokenGrant( + request: Partial, + client: OIDCClient + ): Promise<{ success: true; response: TokenResponse } | { success: false; error: OIDCError }> { + if (!request.refresh_token) { + return { + success: false, + error: { + error: 'invalid_request', + error_description: 'Missing refresh_token', + }, + }; + } + + // 获取刷新令牌 + const refreshToken = await this.tokenManager.getRefreshToken(request.refresh_token); + if (!refreshToken) { + return { + success: false, + error: { + error: 'invalid_grant', + error_description: 'Invalid refresh_token', + }, + }; + } + + // 检查刷新令牌是否过期 + if (refreshToken.expires_at < new Date()) { + await this.tokenManager.deleteRefreshToken(request.refresh_token); + return { + success: false, + error: { + error: 'invalid_grant', + error_description: 'Refresh token expired', + }, + }; + } + + // 验证客户端ID + if (refreshToken.client_id !== request.client_id) { + return { + success: false, + error: { + error: 'invalid_grant', + error_description: 'Refresh token was not issued to this client', + }, + }; + } + + // 获取用户信息 + const user = await this.findUser(refreshToken.user_id); + if (!user) { + return { + success: false, + error: { + error: 'invalid_grant', + error_description: '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 { + success: false, + error: { + error: 'invalid_scope', + error_description: 'Requested scope exceeds original scope', + }, + }; + } + scope = request.scope; + } + + // 生成新的访问令牌 + const now = new Date(); + const accessTokenExpiry = new Date(now.getTime() + this.config.tokenTTL.accessToken * 1000); + + 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, + }); + + const accessToken: AccessToken = { + token: accessTokenJWT, + client_id: request.client_id!, + user_id: user.sub, + scope, + expires_at: accessTokenExpiry, + created_at: now, + }; + + await this.tokenManager.storeAccessToken(accessToken); + + // 可选:生成新的刷新令牌(刷新令牌轮换) + let newRefreshToken: string | undefined; + if (this.config.rotateRefreshTokens !== false) { // 默认启用刷新令牌轮换 + const refreshTokenExpiry = new Date(now.getTime() + this.config.tokenTTL.refreshToken * 1000); + newRefreshToken = nanoid(64); + + const newRefreshTokenRecord: RefreshToken = { + token: newRefreshToken, + client_id: request.client_id!, + user_id: user.sub, + scope, + expires_at: refreshTokenExpiry, + created_at: now, + }; + + await this.tokenManager.storeRefreshToken(newRefreshTokenRecord); + // 删除旧的刷新令牌 + await this.tokenManager.deleteRefreshToken(request.refresh_token); + } + + const response: TokenResponse = { + access_token: accessTokenJWT, + token_type: 'Bearer', + expires_in: this.config.tokenTTL.accessToken, + scope, + }; + + if (newRefreshToken) { + response.refresh_token = newRefreshToken; + } + + return { + success: true, + 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: { + error: 'invalid_token', + error_description: 'Invalid token type', + }, + }; + } + + // 获取令牌数据 + const tokenData = await this.tokenManager.getAccessToken(accessToken); + if (!tokenData) { + return { + success: false, + error: { + error: 'invalid_token', + error_description: 'Token not found', + }, + }; + } + + // 检查令牌是否过期 + if (tokenData.expires_at < new Date()) { + await this.tokenManager.deleteAccessToken(accessToken); + return { + success: false, + error: { + error: 'invalid_token', + error_description: 'Token expired', + }, + }; + } + + // 获取用户信息 + const user = await this.findUser(tokenData.user_id); + if (!user) { + return { + success: false, + error: { + error: 'invalid_token', + error_description: '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: { + error: 'invalid_token', + error_description: 'Invalid token', + }, + }; + } + } + + /** + * 根据作用域获取请求的声明 + */ + private getRequestedClaims(scope: string): string[] { + const scopes = scope.split(' '); + const claims: string[] = ['sub']; // 总是包含sub + + if (scopes.includes('profile')) { + claims.push( + 'name', 'family_name', 'given_name', 'middle_name', 'nickname', + 'preferred_username', 'profile', 'picture', 'website', 'gender', + 'birthdate', 'zoneinfo', 'locale', 'updated_at' + ); + } + + if (scopes.includes('email')) { + claims.push('email', 'email_verified'); + } + + if (scopes.includes('phone')) { + claims.push('phone_number', 'phone_number_verified'); + } + + if (scopes.includes('address')) { + claims.push('address'); + } + + return claims; + } + + /** + * 根据请求的声明过滤用户信息 + */ + private filterUserClaims(user: OIDCUser, requestedClaims: string[]): Partial { + const filtered: Partial = {}; + + for (const claim of requestedClaims) { + if (claim in user && user[claim as keyof OIDCUser] !== undefined) { + (filtered as any)[claim] = user[claim as keyof OIDCUser]; + } + } + + return filtered; + } + + /** + * 撤销令牌 + */ + async revokeToken(token: string, tokenTypeHint?: string): Promise<{ success: boolean; error?: OIDCError }> { + try { + // 尝试作为访问令牌撤销 + const accessToken = await this.tokenManager.getAccessToken(token); + if (accessToken) { + await this.tokenManager.deleteAccessToken(token); + return { success: true }; + } + + // 尝试作为刷新令牌撤销 + const refreshToken = await this.tokenManager.getRefreshToken(token); + if (refreshToken) { + await this.tokenManager.deleteRefreshToken(token); + // 同时撤销相关的访问令牌 + await this.tokenManager.deleteAccessTokensByUserAndClient(refreshToken.user_id, refreshToken.client_id); + return { success: true }; + } + + // 令牌不存在,根据RFC 7009,这应该返回成功 + return { success: true }; + } catch (error) { + return { + success: false, + error: { + error: 'server_error', + error_description: 'Internal server error', + }, + }; + } + } + + /** + * 内省令牌 + */ + async introspectToken(token: string): Promise<{ + active: boolean; + scope?: string; + client_id?: string; + username?: string; + token_type?: string; + exp?: number; + iat?: number; + sub?: string; + aud?: string; + iss?: string; + }> { + try { + // 尝试作为访问令牌内省 + const accessToken = await this.tokenManager.getAccessToken(token); + if (accessToken) { + const isExpired = accessToken.expires_at < new Date(); + if (isExpired) { + await this.tokenManager.deleteAccessToken(token); + return { active: false }; + } + + const user = await this.findUser(accessToken.user_id); + + return { + active: true, + scope: accessToken.scope, + client_id: accessToken.client_id, + username: user?.username, + token_type: 'Bearer', + exp: Math.floor(accessToken.expires_at.getTime() / 1000), + iat: Math.floor(accessToken.created_at.getTime() / 1000), + sub: accessToken.user_id, + aud: accessToken.client_id, + iss: this.config.issuer, + }; + } + + // 尝试作为刷新令牌内省 + const refreshToken = await this.tokenManager.getRefreshToken(token); + if (refreshToken) { + const isExpired = refreshToken.expires_at < new Date(); + if (isExpired) { + await this.tokenManager.deleteRefreshToken(token); + return { active: false }; + } + + const user = await this.findUser(refreshToken.user_id); + + return { + active: true, + scope: refreshToken.scope, + client_id: refreshToken.client_id, + username: user?.username, + token_type: 'refresh_token', + exp: Math.floor(refreshToken.expires_at.getTime() / 1000), + iat: Math.floor(refreshToken.created_at.getTime() / 1000), + sub: refreshToken.user_id, + aud: refreshToken.client_id, + iss: this.config.issuer, + }; + } + + return { active: false }; + } catch (error) { + return { active: false }; + } + } + + /** + * 获取JWKS + */ + async getJWKS(): Promise<{ keys: any[] }> { + return await this.jwtUtils.generateJWKS(); + } +} \ No newline at end of file diff --git a/packages/oidc-provider/src/storage/adapter.ts b/packages/oidc-provider/src/storage/adapter.ts new file mode 100644 index 0000000..75fed24 --- /dev/null +++ b/packages/oidc-provider/src/storage/adapter.ts @@ -0,0 +1,44 @@ +/** + * 通用存储适配器接口 + * 使用简单的key-value存储模式支持所有OIDC存储需求 + */ +export interface StorageAdapter { + /** + * 存储数据 + * @param key 存储键 + * @param value 存储值 + * @param ttl 过期时间(秒),可选 + */ + set(key: string, value: any, ttl?: number): Promise; + + /** + * 获取数据 + * @param key 存储键 + */ + get(key: string): Promise; + + /** + * 删除数据 + * @param key 存储键 + */ + delete(key: string): Promise; + + /** + * 批量删除匹配模式的键 + * @param pattern 键模式(支持通配符) + */ + deletePattern(pattern: string): Promise; + + /** + * 检查键是否存在 + * @param key 存储键 + */ + exists(key: string): Promise; + + /** + * 设置键的过期时间 + * @param key 存储键 + * @param ttl 过期时间(秒) + */ + expire(key: string, ttl: number): Promise; +} \ No newline at end of file diff --git a/packages/oidc-provider/src/storage/index.ts b/packages/oidc-provider/src/storage/index.ts new file mode 100644 index 0000000..42f0fc3 --- /dev/null +++ b/packages/oidc-provider/src/storage/index.ts @@ -0,0 +1,2 @@ +export type { StorageAdapter } from './adapter'; +export { RedisStorageAdapter } from './redis-adapter'; \ No newline at end of file diff --git a/packages/oidc-provider/src/storage/redis-adapter.ts b/packages/oidc-provider/src/storage/redis-adapter.ts new file mode 100644 index 0000000..4444de8 --- /dev/null +++ b/packages/oidc-provider/src/storage/redis-adapter.ts @@ -0,0 +1,69 @@ +import type { Redis } from 'ioredis'; +import type { StorageAdapter } from './adapter'; + +/** + * Redis存储适配器实现 + * 使用Redis作为通用key-value存储 + */ +export class RedisStorageAdapter implements StorageAdapter { + private redis: Redis; + private keyPrefix: string; + + constructor(redis: Redis, keyPrefix = 'oidc:') { + this.redis = redis; + this.keyPrefix = keyPrefix; + } + + private getKey(key: string): string { + return `${this.keyPrefix}${key}`; + } + + async set(key: string, value: any, ttl?: number): Promise { + const redisKey = this.getKey(key); + const serializedValue = JSON.stringify(value); + + if (ttl) { + await this.redis.setex(redisKey, ttl, serializedValue); + } else { + await this.redis.set(redisKey, serializedValue); + } + } + + async get(key: string): Promise { + const redisKey = this.getKey(key); + const data = await this.redis.get(redisKey); + + if (!data) return null; + + try { + return JSON.parse(data); + } catch { + return data as T; + } + } + + async delete(key: string): Promise { + const redisKey = this.getKey(key); + await this.redis.del(redisKey); + } + + async deletePattern(pattern: string): Promise { + const redisPattern = this.getKey(pattern); + const keys = await this.redis.keys(redisPattern); + + if (keys.length > 0) { + await this.redis.del(...keys); + } + } + + async exists(key: string): Promise { + const redisKey = this.getKey(key); + const result = await this.redis.exists(redisKey); + return result === 1; + } + + async expire(key: string, ttl: number): Promise { + const redisKey = this.getKey(key); + await this.redis.expire(redisKey, ttl); + } +} \ No newline at end of file diff --git a/packages/oidc-provider/src/types/index.ts b/packages/oidc-provider/src/types/index.ts new file mode 100644 index 0000000..7432a09 --- /dev/null +++ b/packages/oidc-provider/src/types/index.ts @@ -0,0 +1,342 @@ +import type { StorageAdapter } from '../storage/adapter'; + +export interface OIDCProviderConfig { + /** 发行者标识符 */ + issuer: string; + /** 签名密钥 */ + signingKey: string; + /** 签名算法 */ + signingAlgorithm?: 'HS256' | 'RS256' | 'ES256'; + /** 存储适配器实例(仅用于令牌存储) */ + storage: StorageAdapter; + /** 查找用户的回调函数 */ + findUser: (userId: string) => Promise; + /** 获取客户端的回调函数 */ + findClient: (clientId: string) => Promise; + /** 令牌过期时间配置 */ + tokenTTL?: { + accessToken?: number; // 默认 3600 秒 + refreshToken?: number; // 默认 30 天 + authorizationCode?: number; // 默认 600 秒 + idToken?: number; // 默认 3600 秒 + }; + /** 支持的响应类型 */ + responseTypes?: string[]; + /** 支持的授权类型 */ + grantTypes?: string[]; + /** 支持的作用域 */ + scopes?: string[]; + /** 支持的声明 */ + claims?: string[]; + /** 是否启用PKCE */ + enablePKCE?: boolean; + /** 是否强制要求PKCE(针对public客户端) */ + requirePKCE?: boolean; + /** 是否启用刷新令牌轮换 */ + rotateRefreshTokens?: boolean; +} + +export interface OIDCClient { + /** 客户端ID */ + client_id: string; + /** 客户端密钥 */ + client_secret?: string; + /** 重定向URI列表 */ + redirect_uris: string[]; + /** 客户端名称 */ + client_name?: string; + /** 客户端类型 */ + client_type: 'public' | 'confidential'; + /** 支持的授权类型 */ + grant_types: string[]; + /** 支持的响应类型 */ + response_types: string[]; + /** 支持的作用域 */ + scopes: string[]; + /** 令牌端点认证方法 */ + token_endpoint_auth_method?: string; + /** 创建时间 */ + created_at: Date; + /** 更新时间 */ + updated_at: Date; +} + +export interface OIDCUser { + /** 用户ID */ + sub: string; + /** 用户名 */ + username?: string; + /** 邮箱 */ + email?: string; + /** 邮箱是否已验证 */ + email_verified?: boolean; + /** 姓名 */ + name?: string; + /** 名字 */ + given_name?: string; + /** 姓氏 */ + family_name?: string; + /** 头像 */ + picture?: string; + /** 个人资料 */ + profile?: string; + /** 网站 */ + website?: string; + /** 性别 */ + gender?: string; + /** 生日 */ + birthdate?: string; + /** 时区 */ + zoneinfo?: string; + /** 语言 */ + locale?: string; + /** 电话号码 */ + phone_number?: string; + /** 电话号码是否已验证 */ + phone_number_verified?: boolean; + /** 地址 */ + address?: { + formatted?: string; + street_address?: string; + locality?: string; + region?: string; + postal_code?: string; + country?: string; + }; + /** 更新时间 */ + updated_at?: number; + /** 自定义声明 */ + [key: string]: any; +} + +export interface AuthorizationCode { + /** 授权码 */ + code: string; + /** 客户端ID */ + client_id: string; + /** 用户ID */ + user_id: string; + /** 重定向URI */ + redirect_uri: string; + /** 作用域 */ + scope: string; + /** PKCE代码挑战 */ + code_challenge?: string; + /** PKCE代码挑战方法 */ + code_challenge_method?: string; + /** 随机数 */ + nonce?: string; + /** 状态 */ + state?: string; + /** 过期时间 */ + expires_at: Date; + /** 创建时间 */ + created_at: Date; +} + +export interface AccessToken { + /** 访问令牌 */ + token: string; + /** 客户端ID */ + client_id: string; + /** 用户ID */ + user_id: string; + /** 作用域 */ + scope: string; + /** 过期时间 */ + expires_at: Date; + /** 创建时间 */ + created_at: Date; +} + +export interface RefreshToken { + /** 刷新令牌 */ + token: string; + /** 客户端ID */ + client_id: string; + /** 用户ID */ + user_id: string; + /** 作用域 */ + scope: string; + /** 过期时间 */ + expires_at: Date; + /** 创建时间 */ + created_at: Date; +} + +export interface IDToken { + /** ID令牌 */ + token: string; + /** 客户端ID */ + client_id: string; + /** 用户ID */ + user_id: string; + /** 随机数 */ + nonce?: string; + /** 过期时间 */ + expires_at: Date; + /** 创建时间 */ + created_at: Date; +} + +export interface AuthorizationRequest { + /** 响应类型 */ + response_type: string; + /** 客户端ID */ + client_id: string; + /** 重定向URI */ + redirect_uri: string; + /** 作用域 */ + scope: string; + /** 状态 */ + state?: string; + /** 随机数 */ + nonce?: string; + /** PKCE代码挑战 */ + code_challenge?: string; + /** PKCE代码挑战方法 */ + code_challenge_method?: 'plain' | 'S256'; + /** 提示 */ + prompt?: string; + /** 最大年龄 */ + max_age?: number; + /** ID令牌提示 */ + id_token_hint?: string; + /** 登录提示 */ + login_hint?: string; + /** ACR值 */ + acr_values?: string; +} + +export interface TokenRequest { + /** 授权类型 */ + grant_type: string; + /** 授权码 */ + code?: string; + /** 重定向URI */ + redirect_uri?: string; + /** 客户端ID */ + client_id: string; + /** 客户端密钥 */ + client_secret?: string; + /** 刷新令牌 */ + refresh_token?: string; + /** 作用域 */ + scope?: string; + /** PKCE代码验证器 */ + code_verifier?: string; +} + +export interface TokenResponse { + /** 访问令牌 */ + access_token: string; + /** 令牌类型 */ + token_type: string; + /** 过期时间(秒) */ + expires_in: number; + /** 刷新令牌 */ + refresh_token?: string; + /** ID令牌 */ + id_token?: string; + /** 作用域 */ + scope?: string; +} + +export interface OIDCError { + /** 错误代码 */ + error: string; + /** 错误描述 */ + error_description?: string; + /** 错误URI */ + error_uri?: string; + /** 状态 */ + state?: string; +} + +export interface DiscoveryDocument { + /** 发行者 */ + issuer: string; + /** 授权端点 */ + authorization_endpoint: string; + /** 令牌端点 */ + token_endpoint: string; + /** 用户信息端点 */ + userinfo_endpoint: string; + /** JWKS URI */ + jwks_uri: string; + /** 注册端点 */ + registration_endpoint?: string; + /** 撤销端点 */ + revocation_endpoint?: string; + /** 内省端点 */ + introspection_endpoint?: string; + /** 结束会话端点 */ + end_session_endpoint?: string; + /** 支持的响应类型 */ + response_types_supported: string[]; + /** 支持的授权类型 */ + grant_types_supported: string[]; + /** 支持的作用域 */ + scopes_supported: string[]; + /** 支持的声明 */ + claims_supported: string[]; + /** 支持的令牌端点认证方法 */ + token_endpoint_auth_methods_supported: string[]; + /** 支持的签名算法 */ + id_token_signing_alg_values_supported: string[]; + /** 支持的主题类型 */ + subject_types_supported: string[]; + /** 支持的代码挑战方法 */ + code_challenge_methods_supported?: string[]; + /** 支持的响应模式 */ + response_modes_supported?: string[]; + /** 是否支持claims参数 */ + claims_parameter_supported?: boolean; + /** 是否支持request参数 */ + request_parameter_supported?: boolean; + /** 是否支持request_uri参数 */ + request_uri_parameter_supported?: boolean; + /** 是否要求注册request_uri */ + require_request_uri_registration?: boolean; +} + +/** + * 用户会话接口 + */ +export interface UserSession { + /** 会话ID */ + session_id: string; + /** 用户ID */ + user_id: string; + /** 客户端ID */ + client_id?: string; + /** 认证时间 */ + auth_time: number; + /** 认证上下文类引用 */ + acr?: string; + /** 认证方法引用 */ + amr?: string[]; + /** 过期时间 */ + expires_at: Date; + /** 创建时间 */ + created_at: Date; + /** 授权参数(用于重定向) */ + authorization_params?: AuthorizationRequest; +} + +/** + * 登录凭据接口 + */ +export interface LoginCredentials { + /** 用户名 */ + username: string; + /** 密码 */ + password: string; + /** 记住我 */ + remember_me?: boolean; +} + +/** + * 密码验证函数类型 + */ +export type PasswordValidator = (username: string, password: string) => Promise; \ No newline at end of file diff --git a/packages/oidc-provider/src/utils/jwt.ts b/packages/oidc-provider/src/utils/jwt.ts new file mode 100644 index 0000000..b70793f --- /dev/null +++ b/packages/oidc-provider/src/utils/jwt.ts @@ -0,0 +1,252 @@ +import { SignJWT, jwtVerify, importJWK, exportJWK, generateKeyPair } from 'jose'; +import type { OIDCUser } from '../types'; + +export interface JWTPayload { + iss: string; + sub: string; + 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; +} + +/** + * JWT工具类 + */ +export class JWTUtils { + private signingKey: string; + private algorithm: 'HS256' | 'RS256' | 'ES256' = 'HS256'; + + constructor(signingKey: string, algorithm?: 'HS256' | 'RS256' | 'ES256') { + this.signingKey = signingKey; + this.algorithm = algorithm || 'HS256'; + } + + /** + * 生成访问令牌 + */ + async generateAccessToken(payload: { + issuer: string; + subject: string; + audience: string; + clientId: string; + scope: string; + expiresIn: number; + }): Promise { + const now = Math.floor(Date.now() / 1000); + + const jwtPayload: AccessTokenPayload = { + iss: payload.issuer, + sub: payload.subject, + aud: payload.audience, + exp: now + payload.expiresIn, + 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)); + } + + /** + * 生成ID令牌 + */ + async generateIDToken(payload: { + issuer: string; + subject: string; + audience: string; + user: OIDCUser; + authTime: number; + nonce?: string; + expiresIn: number; + requestedClaims?: string[]; + }): Promise { + const now = Math.floor(Date.now() / 1000); + + const jwtPayload: IDTokenPayload = { + iss: payload.issuer, + sub: payload.subject, + aud: payload.audience, + exp: now + payload.expiresIn, + iat: now, + auth_time: payload.authTime, + }; + + // 添加nonce(如果提供) + if (payload.nonce) { + jwtPayload.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)); + } + + /** + * 验证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'}`); + } + } + + /** + * 解码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) + */ + async generateJWKS(): Promise<{ keys: any[] }> { + // 对于HMAC算法,我们不暴露密钥 + // 这里返回空的JWKS,实际应用中可能需要使用RSA或ECDSA + return { keys: [] }; + } + + /** + * 生成RSA密钥对(用于生产环境) + */ + static async generateRSAKeyPair(): Promise<{ + privateKey: string; + publicKey: string; + jwk: any; + }> { + const { privateKey, publicKey } = await generateKeyPair('RS256'); + const jwk = await exportJWK(publicKey); + + return { + privateKey: JSON.stringify(await exportJWK(privateKey)), + publicKey: JSON.stringify(jwk), + jwk: { + ...jwk, + alg: 'RS256', + use: 'sig', + kid: 'default', + }, + }; + } +} \ No newline at end of file diff --git a/packages/oidc-provider/src/utils/pkce.ts b/packages/oidc-provider/src/utils/pkce.ts new file mode 100644 index 0000000..5778de7 --- /dev/null +++ b/packages/oidc-provider/src/utils/pkce.ts @@ -0,0 +1,153 @@ +import { createHash } from 'crypto'; + +/** + * PKCE工具类 + * 实现RFC 7636 - Proof Key for Code Exchange + */ +export class PKCEUtils { + /** + * 支持的代码挑战方法 + */ + static readonly SUPPORTED_METHODS = ['plain', 'S256'] as const; + + /** + * 验证代码挑战方法是否支持 + */ + static isSupportedMethod(method: string): method is typeof PKCEUtils.SUPPORTED_METHODS[number] { + return PKCEUtils.SUPPORTED_METHODS.includes(method as any); + } + + /** + * 验证代码验证器格式 + * 根据RFC 7636,代码验证器必须是43-128个字符的字符串 + * 只能包含 [A-Z] / [a-z] / [0-9] / "-" / "." / "_" / "~" + */ + static isValidCodeVerifier(codeVerifier: string): boolean { + if (!codeVerifier || typeof codeVerifier !== 'string') { + return false; + } + + // 检查长度 + if (codeVerifier.length < 43 || codeVerifier.length > 128) { + return false; + } + + // 检查字符集 + const validChars = /^[A-Za-z0-9\-._~]+$/; + return validChars.test(codeVerifier); + } + + /** + * 验证代码挑战格式 + * 代码挑战的格式取决于使用的方法 + */ + static isValidCodeChallenge(codeChallenge: string, method: string): boolean { + if (!codeChallenge || typeof codeChallenge !== 'string') { + return false; + } + + switch (method) { + case 'plain': + // plain方法下,代码挑战就是代码验证器 + return PKCEUtils.isValidCodeVerifier(codeChallenge); + + case 'S256': + // S256方法下,代码挑战是base64url编码的SHA256哈希 + // 长度应该是43个字符(256位哈希的base64url编码) + if (codeChallenge.length !== 43) { + return false; + } + // 检查是否是有效的base64url字符 + const base64urlChars = /^[A-Za-z0-9\-_]+$/; + return base64urlChars.test(codeChallenge); + + default: + return false; + } + } + + /** + * 生成代码挑战 + * 根据代码验证器和方法生成代码挑战 + */ + static generateCodeChallenge(codeVerifier: string, method: string): string { + if (!PKCEUtils.isValidCodeVerifier(codeVerifier)) { + throw new Error('Invalid code verifier'); + } + + if (!PKCEUtils.isSupportedMethod(method)) { + throw new Error(`Unsupported code challenge method: ${method}`); + } + + switch (method) { + case 'plain': + return codeVerifier; + + case 'S256': + const hash = createHash('sha256').update(codeVerifier).digest(); + return hash.toString('base64url'); + + default: + throw new Error(`Unsupported code challenge method: ${method}`); + } + } + + /** + * 验证代码验证器和代码挑战是否匹配 + */ + static verifyCodeChallenge( + codeVerifier: string, + codeChallenge: string, + method: string + ): boolean { + try { + if (!PKCEUtils.isValidCodeVerifier(codeVerifier)) { + return false; + } + + if (!PKCEUtils.isValidCodeChallenge(codeChallenge, method)) { + return false; + } + + const expectedChallenge = PKCEUtils.generateCodeChallenge(codeVerifier, method); + return expectedChallenge === codeChallenge; + } catch { + return false; + } + } + + /** + * 生成随机的代码验证器 + * 用于测试或客户端SDK + */ + static generateCodeVerifier(): string { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'; + const length = 128; // 使用最大长度 + let result = ''; + + for (let i = 0; i < length; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)); + } + + return result; + } + + /** + * 生成代码挑战对(验证器和挑战) + * 用于测试或客户端SDK + */ + static generateCodeChallengePair(method: string = 'S256'): { + codeVerifier: string; + codeChallenge: string; + codeChallengeMethod: string; + } { + const codeVerifier = PKCEUtils.generateCodeVerifier(); + const codeChallenge = PKCEUtils.generateCodeChallenge(codeVerifier, method); + + return { + codeVerifier, + codeChallenge, + codeChallengeMethod: method, + }; + } +} \ No newline at end of file diff --git a/packages/oidc-provider/src/utils/validation.ts b/packages/oidc-provider/src/utils/validation.ts new file mode 100644 index 0000000..de1cb64 --- /dev/null +++ b/packages/oidc-provider/src/utils/validation.ts @@ -0,0 +1,314 @@ +import { z } from 'zod'; +import type { AuthorizationRequest, TokenRequest, OIDCClient } from '../types'; + +/** + * OIDC验证工具类 + */ +export class ValidationUtils { + /** + * 验证重定向URI + */ + static isValidRedirectUri(uri: string): boolean { + try { + const url = new URL(uri); + // 不允许fragment + if (url.hash) { + return false; + } + // 必须是HTTPS(除了localhost) + if (url.protocol !== 'https:' && url.hostname !== 'localhost' && url.hostname !== '127.0.0.1') { + return false; + } + return true; + } catch { + return false; + } + } + + /** + * 验证作用域 + */ + static isValidScope(scope: string, supportedScopes: string[]): boolean { + if (!scope || typeof scope !== 'string') { + return false; + } + + const requestedScopes = scope.split(' ').filter(s => s.length > 0); + + // 必须包含openid作用域 + if (!requestedScopes.includes('openid')) { + return false; + } + + // 检查所有请求的作用域是否都被支持 + return requestedScopes.every(s => supportedScopes.includes(s)); + } + + /** + * 验证响应类型 + */ + static isValidResponseType(responseType: string, supportedTypes: string[]): boolean { + if (!responseType || typeof responseType !== 'string') { + return false; + } + + return supportedTypes.includes(responseType); + } + + /** + * 验证授权类型 + */ + static isValidGrantType(grantType: string, supportedTypes: string[]): boolean { + if (!grantType || typeof grantType !== 'string') { + return false; + } + + return supportedTypes.includes(grantType); + } + + /** + * 验证客户端ID格式 + */ + static isValidClientId(clientId: string): boolean { + if (!clientId || typeof clientId !== 'string') { + return false; + } + + // 客户端ID应该是非空字符串,长度在3-128之间 + return clientId.length >= 3 && clientId.length <= 128; + } + + /** + * 验证状态参数 + */ + static isValidState(state: string): boolean { + if (!state || typeof state !== 'string') { + return false; + } + + // 状态参数应该是非空字符串,长度不超过512 + return state.length > 0 && state.length <= 512; + } + + /** + * 验证随机数 + */ + static isValidNonce(nonce: string): boolean { + if (!nonce || typeof nonce !== 'string') { + return false; + } + + // 随机数应该是非空字符串,长度不超过512 + return nonce.length > 0 && nonce.length <= 512; + } + + /** + * 验证授权请求 + */ + static validateAuthorizationRequest( + request: Partial, + client: OIDCClient, + supportedScopes: string[], + supportedResponseTypes: string[] + ): { valid: boolean; errors: string[] } { + const errors: string[] = []; + + // 验证响应类型 + if (!request.response_type) { + errors.push('Missing response_type parameter'); + } else if (!ValidationUtils.isValidResponseType(request.response_type, supportedResponseTypes)) { + errors.push('Invalid or unsupported response_type'); + } else if (!client.response_types.includes(request.response_type)) { + errors.push('Response type not allowed for this client'); + } + + // 验证客户端ID + if (!request.client_id) { + errors.push('Missing client_id parameter'); + } else if (request.client_id !== client.client_id) { + errors.push('Invalid client_id'); + } + + // 验证重定向URI + if (!request.redirect_uri) { + errors.push('Missing redirect_uri parameter'); + } else if (!ValidationUtils.isValidRedirectUri(request.redirect_uri)) { + errors.push('Invalid redirect_uri format'); + } else if (!client.redirect_uris.includes(request.redirect_uri)) { + errors.push('Redirect URI not registered for this client'); + } + + // 验证作用域 + if (!request.scope) { + errors.push('Missing scope parameter'); + } else if (!ValidationUtils.isValidScope(request.scope, supportedScopes)) { + errors.push('Invalid or unsupported scope'); + } else { + const requestedScopes = request.scope.split(' '); + const allowedScopes = client.scopes; + if (!requestedScopes.every(s => allowedScopes.includes(s))) { + errors.push('Scope not allowed for this client'); + } + } + + // 验证状态(可选) + if (request.state && !ValidationUtils.isValidState(request.state)) { + errors.push('Invalid state parameter'); + } + + // 验证随机数(可选) + if (request.nonce && !ValidationUtils.isValidNonce(request.nonce)) { + errors.push('Invalid nonce parameter'); + } + + // 验证PKCE参数(如果提供) + if (request.code_challenge) { + if (!request.code_challenge_method) { + errors.push('Missing code_challenge_method when code_challenge is provided'); + } else if (!['plain', 'S256'].includes(request.code_challenge_method)) { + errors.push('Unsupported code_challenge_method'); + } + } + + return { + valid: errors.length === 0, + errors, + }; + } + + /** + * 验证令牌请求 + */ + static validateTokenRequest( + request: Partial, + client: OIDCClient, + supportedGrantTypes: string[] + ): { valid: boolean; errors: string[] } { + const errors: string[] = []; + + // 验证授权类型 + if (!request.grant_type) { + errors.push('Missing grant_type parameter'); + } else if (!ValidationUtils.isValidGrantType(request.grant_type, supportedGrantTypes)) { + errors.push('Invalid or unsupported grant_type'); + } else if (!client.grant_types.includes(request.grant_type)) { + errors.push('Grant type not allowed for this client'); + } + + // 验证客户端ID + if (!request.client_id) { + errors.push('Missing client_id parameter'); + } else if (request.client_id !== client.client_id) { + errors.push('Invalid client_id'); + } + + // 根据授权类型进行特定验证 + if (request.grant_type === 'authorization_code') { + if (!request.code) { + errors.push('Missing code parameter for authorization_code grant'); + } + if (!request.redirect_uri) { + errors.push('Missing redirect_uri parameter for authorization_code grant'); + } + } else if (request.grant_type === 'refresh_token') { + if (!request.refresh_token) { + errors.push('Missing refresh_token parameter for refresh_token grant'); + } + } + + return { + valid: errors.length === 0, + errors, + }; + } + + /** + * 验证用户信息请求的访问令牌 + */ + static validateBearerToken(authorizationHeader: string | undefined): string | null { + if (!authorizationHeader) { + return null; + } + + const parts = authorizationHeader.split(' '); + if (parts.length !== 2 || parts[0]?.toLowerCase() !== 'bearer') { + return null; + } + + const token = parts[1]; + if (!token || token.length === 0) { + return null; + } + + return token; + } + + /** + * 验证邮箱格式 + */ + static isValidEmail(email: string): boolean { + const emailSchema = z.string().email(); + try { + emailSchema.parse(email); + return true; + } catch { + return false; + } + } + + /** + * 验证URL格式 + */ + static isValidUrl(url: string): boolean { + try { + new URL(url); + return true; + } catch { + return false; + } + } + + /** + * 验证电话号码格式(简单验证) + */ + static isValidPhoneNumber(phone: string): boolean { + // 简单的电话号码验证,支持国际格式 + const phoneRegex = /^\+?[1-9]\d{1,14}$/; + return phoneRegex.test(phone); + } + + /** + * 验证日期格式(YYYY-MM-DD) + */ + static isValidBirthdate(birthdate: string): boolean { + const dateRegex = /^\d{4}-\d{2}-\d{2}$/; + if (!dateRegex.test(birthdate)) { + return false; + } + + const date = new Date(birthdate); + return !isNaN(date.getTime()); + } + + /** + * 验证时区格式 + */ + static isValidZoneinfo(zoneinfo: string): boolean { + try { + // 尝试使用Intl.DateTimeFormat验证时区 + Intl.DateTimeFormat(undefined, { timeZone: zoneinfo }); + return true; + } catch { + return false; + } + } + + /** + * 验证语言标签格式 + */ + static isValidLocale(locale: string): boolean { + // 简单的语言标签验证(如:en, en-US, zh-CN) + const localeRegex = /^[a-z]{2,3}(-[A-Z]{2})?$/; + return localeRegex.test(locale); + } +} \ No newline at end of file diff --git a/packages/oidc-provider/tsconfig.json b/packages/oidc-provider/tsconfig.json new file mode 100644 index 0000000..3a8900e --- /dev/null +++ b/packages/oidc-provider/tsconfig.json @@ -0,0 +1,29 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "noEmitOnError": false + }, + "include": [ + "src/**/*" + ], + "exclude": [ + "dist", + "node_modules", + "**/*.test.ts", + "**/*.spec.ts" + ] +} \ No newline at end of file diff --git a/packages/ui/package.json b/packages/ui/package.json index 5833cd6..37ff540 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -14,8 +14,12 @@ "lint": "eslint . --max-warnings 0" }, "dependencies": { + "@radix-ui/react-avatar": "^1.1.10", + "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-dropdown-menu": "^2.1.14", - "@radix-ui/react-slot": "^1.2.2", + "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-separator": "^1.1.7", + "@radix-ui/react-slot": "^1.2.3", "clsx": "^2.1.1", "react": "^19.1.0", "react-dom": "^19.1.0", @@ -23,13 +27,13 @@ "zod": "^3.24.4" }, "devDependencies": { + "@repo/eslint-config": "workspace:*", + "@repo/typescript-config": "workspace:*", "@tailwindcss/postcss": "^4", "@turbo/gen": "^2.5.3", "@types/node": "^20", "@types/react": "^19.1.4", "@types/react-dom": "^19.1.5", - "@repo/eslint-config": "workspace:*", - "@repo/typescript-config": "workspace:*", "class-variance-authority": "^0.7.1", "lucide-react": "0.511.0", "tailwindcss": "^4", diff --git a/packages/ui/src/components/alert.tsx b/packages/ui/src/components/alert.tsx new file mode 100644 index 0000000..4b121f2 --- /dev/null +++ b/packages/ui/src/components/alert.tsx @@ -0,0 +1,66 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@repo/ui/lib/utils" + +const alertVariants = cva( + "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", + { + variants: { + variant: { + default: "bg-card text-card-foreground", + destructive: + "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Alert({ + className, + variant, + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ) +} + +function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDescription({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { Alert, AlertTitle, AlertDescription } diff --git a/packages/ui/src/components/avatar.tsx b/packages/ui/src/components/avatar.tsx new file mode 100644 index 0000000..c59a619 --- /dev/null +++ b/packages/ui/src/components/avatar.tsx @@ -0,0 +1,53 @@ +"use client" + +import * as React from "react" +import * as AvatarPrimitive from "@radix-ui/react-avatar" + +import { cn } from "@repo/ui/lib/utils" + +function Avatar({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarImage({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarFallback({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/packages/ui/src/components/badge.tsx b/packages/ui/src/components/badge.tsx new file mode 100644 index 0000000..006d0f4 --- /dev/null +++ b/packages/ui/src/components/badge.tsx @@ -0,0 +1,46 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@repo/ui/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", + secondary: + "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", + destructive: + "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Badge({ + className, + variant, + asChild = false, + ...props +}: React.ComponentProps<"span"> & + VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot : "span" + + return ( + + ) +} + +export { Badge, badgeVariants } diff --git a/packages/ui/src/components/breadcrumb.tsx b/packages/ui/src/components/breadcrumb.tsx new file mode 100644 index 0000000..8f7aa07 --- /dev/null +++ b/packages/ui/src/components/breadcrumb.tsx @@ -0,0 +1,109 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { ChevronRight, MoreHorizontal } from "lucide-react" + +import { cn } from "@repo/ui/lib/utils" + +function Breadcrumb({ ...props }: React.ComponentProps<"nav">) { + return