From 26ebb1cf2d1c310c4a0dbf10f8b0e1cfb47494f1 Mon Sep 17 00:00:00 2001 From: longdayi <13477510+longdayilongdayi@user.noreply.gitee.com> Date: Wed, 28 May 2025 08:23:14 +0800 Subject: [PATCH] 05280823 --- .cursorignore | 1 + OIDC_ARCHITECTURE_UPDATE.md | 133 +++ apps/backend/README.md | 287 +++++- apps/backend/package.json | 9 +- apps/backend/src/index.ts | 38 +- apps/backend/src/oidc-demo.ts | 134 +++ apps/backend/src/oidc/config.ts | 109 -- apps/backend/src/oidc/provider.ts | 6 - apps/backend/src/oidc/redis-adapter.ts | 86 -- apps/backend/src/user/user.trpc.ts | 2 +- apps/web/app/auth/callback/page.tsx | 120 +++ apps/web/app/opengraph-image.png | Bin 98947 -> 0 bytes apps/web/app/page.tsx | 179 +++- apps/web/app/test-oidc/page.tsx | 248 +++++ apps/web/components/login-button.tsx | 31 + apps/web/components/providers.tsx | 5 +- apps/web/components/user-profile.tsx | 251 +++++ apps/web/lib/oidc-config.ts | 27 + apps/web/package.json | 11 +- apps/web/providers/auth-provider.tsx | 130 +++ packages/client/package.json | 11 +- packages/oidc-provider/package.json | 60 ++ .../oidc-provider/src/auth/auth-manager.ts | 157 +++ packages/oidc-provider/src/auth/index.ts | 13 + .../oidc-provider/src/auth/session-manager.ts | 83 ++ .../auth/strategies/password-auth-strategy.ts | 304 ++++++ .../oidc-provider/src/auth/token-manager.ts | 205 ++++ .../src/auth/utils/cookie-utils.ts | 121 +++ .../src/auth/utils/html-templates.ts | 341 +++++++ packages/oidc-provider/src/index.ts | 60 ++ packages/oidc-provider/src/middleware/hono.ts | 335 +++++++ packages/oidc-provider/src/provider.ts | 932 ++++++++++++++++++ packages/oidc-provider/src/storage/adapter.ts | 44 + packages/oidc-provider/src/storage/index.ts | 2 + .../src/storage/redis-adapter.ts | 69 ++ packages/oidc-provider/src/types/index.ts | 342 +++++++ packages/oidc-provider/src/utils/jwt.ts | 252 +++++ packages/oidc-provider/src/utils/pkce.ts | 153 +++ .../oidc-provider/src/utils/validation.ts | 314 ++++++ packages/oidc-provider/tsconfig.json | 29 + packages/ui/package.json | 10 +- packages/ui/src/components/alert.tsx | 66 ++ packages/ui/src/components/avatar.tsx | 53 + packages/ui/src/components/badge.tsx | 46 + packages/ui/src/components/breadcrumb.tsx | 109 ++ packages/ui/src/components/button.tsx | 91 +- packages/ui/src/components/card.tsx | 92 ++ packages/ui/src/components/dialog.tsx | 135 +++ packages/ui/src/components/input.tsx | 21 + packages/ui/src/components/label.tsx | 24 + packages/ui/src/components/separator.tsx | 28 + pnpm-lock.yaml | 673 ++++++++++++- test-oidc.ts | 1 + 53 files changed, 6687 insertions(+), 296 deletions(-) create mode 100644 .cursorignore create mode 100644 OIDC_ARCHITECTURE_UPDATE.md create mode 100644 apps/backend/src/oidc-demo.ts delete mode 100644 apps/backend/src/oidc/config.ts delete mode 100644 apps/backend/src/oidc/provider.ts delete mode 100644 apps/backend/src/oidc/redis-adapter.ts create mode 100644 apps/web/app/auth/callback/page.tsx delete mode 100644 apps/web/app/opengraph-image.png create mode 100644 apps/web/app/test-oidc/page.tsx create mode 100644 apps/web/components/login-button.tsx create mode 100644 apps/web/components/user-profile.tsx create mode 100644 apps/web/lib/oidc-config.ts create mode 100644 apps/web/providers/auth-provider.tsx create mode 100644 packages/oidc-provider/package.json create mode 100644 packages/oidc-provider/src/auth/auth-manager.ts create mode 100644 packages/oidc-provider/src/auth/index.ts create mode 100644 packages/oidc-provider/src/auth/session-manager.ts create mode 100644 packages/oidc-provider/src/auth/strategies/password-auth-strategy.ts create mode 100644 packages/oidc-provider/src/auth/token-manager.ts create mode 100644 packages/oidc-provider/src/auth/utils/cookie-utils.ts create mode 100644 packages/oidc-provider/src/auth/utils/html-templates.ts create mode 100644 packages/oidc-provider/src/index.ts create mode 100644 packages/oidc-provider/src/middleware/hono.ts create mode 100644 packages/oidc-provider/src/provider.ts create mode 100644 packages/oidc-provider/src/storage/adapter.ts create mode 100644 packages/oidc-provider/src/storage/index.ts create mode 100644 packages/oidc-provider/src/storage/redis-adapter.ts create mode 100644 packages/oidc-provider/src/types/index.ts create mode 100644 packages/oidc-provider/src/utils/jwt.ts create mode 100644 packages/oidc-provider/src/utils/pkce.ts create mode 100644 packages/oidc-provider/src/utils/validation.ts create mode 100644 packages/oidc-provider/tsconfig.json create mode 100644 packages/ui/src/components/alert.tsx create mode 100644 packages/ui/src/components/avatar.tsx create mode 100644 packages/ui/src/components/badge.tsx create mode 100644 packages/ui/src/components/breadcrumb.tsx create mode 100644 packages/ui/src/components/card.tsx create mode 100644 packages/ui/src/components/dialog.tsx create mode 100644 packages/ui/src/components/input.tsx create mode 100644 packages/ui/src/components/label.tsx create mode 100644 packages/ui/src/components/separator.tsx create mode 100644 test-oidc.ts 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 6bb44a7..b4e4930 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -5,11 +5,12 @@ }, "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", - "@types/oidc-provider": "^9.1.0", "hono": "^4.7.10", "ioredis": "5.4.1", "jose": "^6.0.11", @@ -18,10 +19,14 @@ "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/node": "^22.15.21", + "@types/oidc-provider": "^9.1.0", + "supertest": "^7.1.1", + "vitest": "^3.1.4" } } \ No newline at end of file diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index b9374a6..4681878 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -1,17 +1,16 @@ import { Hono } from 'hono' -import { logger } from 'hono/logger' import { contextStorage, getContext } from 'hono/context-storage' import { prettyJSON } from 'hono/pretty-json' import { cors } from 'hono/cors' - import { trpcServer } from '@hono/trpc-server' 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 { oidcApp } from './oidc-demo' + type Env = { Variables: { redis: Redis @@ -21,23 +20,25 @@ type Env = { const app = new Hono() +// 全局CORS配置 app.use('*', cors({ - origin: 'http://localhost:3001', - credentials: true, + origin: '*', })) +// 注入依赖 app.use('*', async (c, next) => { c.set('redis', redis) c.set('minio', minioClient) await next() }) -app.use('*', async (c, next) => { - c.set('redis', redis); - await next(); -}); + +// 中间件 app.use(contextStorage()) -app.use(prettyJSON()) // With options: prettyJSON({ space: 4 }) -app.use(logger()) +app.use(prettyJSON()) + +app.route('/oidc', oidcApp) + +// 挂载tRPC app.use( '/trpc/*', trpcServer({ @@ -45,10 +46,11 @@ app.use( }) ) -app.use('/oidc/*', async (c, next) => { - // @ts-ignore - await oidc.callback(c.req.raw, c.res.raw) - // return void 也可以 - return -}) -export default app +// 启动服务器 +const port = parseInt(process.env.PORT || '3000'); +export default { + port, + fetch: app.fetch, +} + +console.log(`🚀 服务器运行在 http://localhost:${port}`) 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 09c71f6..0000000 --- a/apps/backend/src/oidc/config.ts +++ /dev/null @@ -1,109 +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; \ No newline at end of file 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 f2f8ff45653e1656d66187b1f71a05acbd54f1de..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 98947 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 AtI-i0*IGZ#d^#i$GvSBx(W~ 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

Pw7t;V@1;&fUcBMST<8c3A5 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 F{|Ej))*=7^ diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 60c4991..41faba0 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -1,13 +1,172 @@ 'use client'; -import { useTRPC } from '@repo/client'; -import { useQuery } from '@tanstack/react-query'; -import { useEffect } from 'react'; -export default function Home() { - const trpc = useTRPC(); - const { data, isLoading } = useQuery(trpc.user.getUser.queryOptions()); - useEffect(() => { - console.log(data); - }, [data]); - return

123
; +import { useAuth } from '@/providers/auth-provider'; +import { UserProfile } from '@/components/user-profile'; +import { LoginButton } from '@/components/login-button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@repo/ui/components/card'; +import { Badge } from '@repo/ui/components/badge'; +import { Separator } from '@repo/ui/components/separator'; +import { Shield, Key, Users, CheckCircle, Info } from 'lucide-react'; + +export default function HomePage() { + const { isAuthenticated, isLoading, error } = useAuth(); + + return ( +
+
+ {/* 页面标题 */} +
+

OIDC 认证演示

+

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

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

演示账户信息:

+
+

+ 用户名: demouser +

+

+ 密码: demo123 +

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

前端技术栈

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

后端技术栈

+
+ Hono + OIDC Provider + Redis + JWT + PKCE +
+
+ +
+

安全特性

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

支持的作用域

+
+ openid + profile + email + phone + address +
+
+
+
+
+
+
+
+ ); } 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 cf560aa..78e928e 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -4,12 +4,16 @@ "type": "module", "private": true, "scripts": { - "dev": "next dev --turbopack", + "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:*", @@ -23,15 +27,16 @@ "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" + "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", 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