05280823
This commit is contained in:
parent
01cc5fe292
commit
26ebb1cf2d
|
@ -0,0 +1 @@
|
||||||
|
# Add directories or file patterns to ignore during indexing (e.g. foo/ or *.csv)
|
|
@ -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 中处理
|
|
@ -1,11 +1,286 @@
|
||||||
To install dependencies:
|
# OIDC Provider Demo
|
||||||
```sh
|
|
||||||
bun install
|
这是一个基于 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<string | null> {
|
||||||
|
// 检查用户认证状态
|
||||||
|
// 例如:从JWT token、session或cookie中获取用户ID
|
||||||
|
const token = c.req.header('Authorization');
|
||||||
|
return await verifyTokenAndGetUserId(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleAuthRequired(c: Context, authRequest: AuthorizationRequest): Promise<Response> {
|
||||||
|
// 处理未认证用户
|
||||||
|
// 例如:显示自定义登录页面或重定向到外部认证服务
|
||||||
|
return this.showCustomLoginPage(c, authRequest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const oidcApp = createOIDCProvider({
|
||||||
|
config: oidcConfig,
|
||||||
|
authHandler: new MyAuthHandler()
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## 启动服务
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 在项目根目录
|
||||||
|
cd apps/backend
|
||||||
bun run dev
|
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` 中的登录处理逻辑。
|
|
@ -5,11 +5,12 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@elastic/elasticsearch": "^9.0.2",
|
"@elastic/elasticsearch": "^9.0.2",
|
||||||
|
"@hono/node-server": "^1.14.3",
|
||||||
"@hono/trpc-server": "^0.3.4",
|
"@hono/trpc-server": "^0.3.4",
|
||||||
"@hono/zod-validator": "^0.5.0",
|
"@hono/zod-validator": "^0.5.0",
|
||||||
"@repo/db": "workspace:*",
|
"@repo/db": "workspace:*",
|
||||||
|
"@repo/oidc-provider": "workspace:*",
|
||||||
"@trpc/server": "11.1.2",
|
"@trpc/server": "11.1.2",
|
||||||
"@types/oidc-provider": "^9.1.0",
|
|
||||||
"hono": "^4.7.10",
|
"hono": "^4.7.10",
|
||||||
"ioredis": "5.4.1",
|
"ioredis": "5.4.1",
|
||||||
"jose": "^6.0.11",
|
"jose": "^6.0.11",
|
||||||
|
@ -18,10 +19,14 @@
|
||||||
"node-cron": "^4.0.7",
|
"node-cron": "^4.0.7",
|
||||||
"oidc-provider": "^9.1.1",
|
"oidc-provider": "^9.1.1",
|
||||||
"superjson": "^2.2.2",
|
"superjson": "^2.2.2",
|
||||||
|
"valibot": "^1.1.0",
|
||||||
"zod": "^3.25.23"
|
"zod": "^3.25.23"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest",
|
"@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"
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,17 +1,16 @@
|
||||||
import { Hono } from 'hono'
|
import { Hono } from 'hono'
|
||||||
import { logger } from 'hono/logger'
|
|
||||||
import { contextStorage, getContext } from 'hono/context-storage'
|
import { contextStorage, getContext } from 'hono/context-storage'
|
||||||
import { prettyJSON } from 'hono/pretty-json'
|
import { prettyJSON } from 'hono/pretty-json'
|
||||||
import { cors } from 'hono/cors'
|
import { cors } from 'hono/cors'
|
||||||
|
|
||||||
import { trpcServer } from '@hono/trpc-server'
|
import { trpcServer } from '@hono/trpc-server'
|
||||||
|
|
||||||
import Redis from 'ioredis'
|
import Redis from 'ioredis'
|
||||||
import redis from './redis'
|
import redis from './redis'
|
||||||
import minioClient from './minio'
|
import minioClient from './minio'
|
||||||
import { Client } from 'minio'
|
import { Client } from 'minio'
|
||||||
import oidc from './oidc/provider'
|
|
||||||
import { appRouter } from './trpc'
|
import { appRouter } from './trpc'
|
||||||
|
import { oidcApp } from './oidc-demo'
|
||||||
|
|
||||||
type Env = {
|
type Env = {
|
||||||
Variables: {
|
Variables: {
|
||||||
redis: Redis
|
redis: Redis
|
||||||
|
@ -21,23 +20,25 @@ type Env = {
|
||||||
|
|
||||||
const app = new Hono<Env>()
|
const app = new Hono<Env>()
|
||||||
|
|
||||||
|
// 全局CORS配置
|
||||||
app.use('*', cors({
|
app.use('*', cors({
|
||||||
origin: 'http://localhost:3001',
|
origin: '*',
|
||||||
credentials: true,
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
// 注入依赖
|
||||||
app.use('*', async (c, next) => {
|
app.use('*', async (c, next) => {
|
||||||
c.set('redis', redis)
|
c.set('redis', redis)
|
||||||
c.set('minio', minioClient)
|
c.set('minio', minioClient)
|
||||||
await next()
|
await next()
|
||||||
})
|
})
|
||||||
app.use('*', async (c, next) => {
|
|
||||||
c.set('redis', redis);
|
// 中间件
|
||||||
await next();
|
|
||||||
});
|
|
||||||
app.use(contextStorage())
|
app.use(contextStorage())
|
||||||
app.use(prettyJSON()) // With options: prettyJSON({ space: 4 })
|
app.use(prettyJSON())
|
||||||
app.use(logger())
|
|
||||||
|
app.route('/oidc', oidcApp)
|
||||||
|
|
||||||
|
// 挂载tRPC
|
||||||
app.use(
|
app.use(
|
||||||
'/trpc/*',
|
'/trpc/*',
|
||||||
trpcServer({
|
trpcServer({
|
||||||
|
@ -45,10 +46,11 @@ app.use(
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
app.use('/oidc/*', async (c, next) => {
|
// 启动服务器
|
||||||
// @ts-ignore
|
const port = parseInt(process.env.PORT || '3000');
|
||||||
await oidc.callback(c.req.raw, c.res.raw)
|
export default {
|
||||||
// return void 也可以
|
port,
|
||||||
return
|
fetch: app.fetch,
|
||||||
})
|
}
|
||||||
export default app
|
|
||||||
|
console.log(`🚀 服务器运行在 http://localhost:${port}`)
|
||||||
|
|
|
@ -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<OIDCClient | null> {
|
||||||
|
return demoClients.find(client => client.client_id === clientId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查找用户的函数
|
||||||
|
async function findUser(userId: string): Promise<OIDCUser | null> {
|
||||||
|
return demoUsers.find(user => user.sub === userId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 密码验证函数
|
||||||
|
async function validatePassword(username: string, password: string): Promise<string | null> {
|
||||||
|
// 查找用户并验证密码
|
||||||
|
const user = demoUsers.find(u => u.username === username);
|
||||||
|
if (!user || password !== 'demo123') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return user.sub; // 返回用户ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// OIDC Provider 配置
|
||||||
|
const oidcConfig: OIDCProviderConfig = {
|
||||||
|
issuer: 'http://localhost:3000/oidc',
|
||||||
|
signingKey: 'your-super-secret-signing-key-at-least-32-characters-long',
|
||||||
|
signingAlgorithm: 'HS256',
|
||||||
|
storage: new RedisStorageAdapter(redis),
|
||||||
|
findClient,
|
||||||
|
findUser,
|
||||||
|
tokenTTL: {
|
||||||
|
accessToken: 3600, // 1小时
|
||||||
|
refreshToken: 30 * 24 * 3600, // 30天
|
||||||
|
authorizationCode: 600, // 10分钟
|
||||||
|
idToken: 3600, // 1小时
|
||||||
|
},
|
||||||
|
responseTypes: ['code'],
|
||||||
|
grantTypes: ['authorization_code', 'refresh_token'],
|
||||||
|
scopes: ['openid', 'profile', 'email', 'phone', 'address'],
|
||||||
|
claims: [
|
||||||
|
'sub', 'name', 'given_name', 'family_name', 'middle_name', 'nickname',
|
||||||
|
'preferred_username', 'profile', 'picture', 'website', 'email',
|
||||||
|
'email_verified', 'gender', 'birthdate', 'zoneinfo', 'locale',
|
||||||
|
'phone_number', 'phone_number_verified', 'address', 'updated_at'
|
||||||
|
],
|
||||||
|
enablePKCE: true,
|
||||||
|
requirePKCE: false,
|
||||||
|
rotateRefreshTokens: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 使用新的内置认证处理器创建OIDC Provider
|
||||||
|
export const oidcApp = createOIDCProvider({
|
||||||
|
config: oidcConfig,
|
||||||
|
useBuiltInAuth: true,
|
||||||
|
builtInAuthConfig: {
|
||||||
|
passwordValidator: validatePassword,
|
||||||
|
sessionTTL: 24 * 60 * 60, // 24小时
|
||||||
|
loginPageTitle: 'OIDC Demo 登录',
|
||||||
|
brandName: 'OIDC Demo Provider',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 导出示例数据用于测试
|
||||||
|
export { demoClients, demoUsers, oidcConfig };
|
|
@ -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;
|
|
|
@ -1,6 +0,0 @@
|
||||||
import { Provider } from 'oidc-provider';
|
|
||||||
import config from './config';
|
|
||||||
|
|
||||||
const oidc = new Provider('http://localhost:3000', config);
|
|
||||||
|
|
||||||
export default oidc;
|
|
|
@ -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}`;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -3,6 +3,6 @@ import { publicProcedure, router } from "../trpc/base"
|
||||||
export const userRouter = router({
|
export const userRouter = router({
|
||||||
getUser: publicProcedure.query(async ({ ctx }) => {
|
getUser: publicProcedure.query(async ({ ctx }) => {
|
||||||
|
|
||||||
return '123'
|
return '1234'
|
||||||
})
|
})
|
||||||
})
|
})
|
|
@ -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<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleCallback = async () => {
|
||||||
|
try {
|
||||||
|
if (!userManager) {
|
||||||
|
throw new Error('用户管理器未初始化');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理OIDC回调
|
||||||
|
const user = await userManager.signinRedirectCallback();
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
setStatus('success');
|
||||||
|
// 延迟跳转到首页
|
||||||
|
setTimeout(() => {
|
||||||
|
router.push('/');
|
||||||
|
}, 2000);
|
||||||
|
} else {
|
||||||
|
throw new Error('未收到用户信息');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('回调处理失败:', err);
|
||||||
|
setError(err instanceof Error ? err.message : '未知错误');
|
||||||
|
setStatus('error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 检查是否有授权码或错误参数
|
||||||
|
const code = searchParams.get('code');
|
||||||
|
const error = searchParams.get('error');
|
||||||
|
const errorDescription = searchParams.get('error_description');
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
setError(`${error}: ${errorDescription || '授权失败'}`);
|
||||||
|
setStatus('error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code) {
|
||||||
|
handleCallback();
|
||||||
|
} else {
|
||||||
|
setError('缺少授权码');
|
||||||
|
setStatus('error');
|
||||||
|
}
|
||||||
|
}, [searchParams, router]);
|
||||||
|
|
||||||
|
const getStatusIcon = () => {
|
||||||
|
switch (status) {
|
||||||
|
case 'loading':
|
||||||
|
return <Loader2 className="h-8 w-8 animate-spin text-blue-500" />;
|
||||||
|
case 'success':
|
||||||
|
return <CheckCircle className="h-8 w-8 text-green-500" />;
|
||||||
|
case 'error':
|
||||||
|
return <XCircle className="h-8 w-8 text-red-500" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusMessage = () => {
|
||||||
|
switch (status) {
|
||||||
|
case 'loading':
|
||||||
|
return '正在处理登录回调...';
|
||||||
|
case 'success':
|
||||||
|
return '登录成功!正在跳转...';
|
||||||
|
case 'error':
|
||||||
|
return '登录失败';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<div className="flex justify-center mb-4">{getStatusIcon()}</div>
|
||||||
|
<CardTitle className="text-xl">{getStatusMessage()}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{status === 'error' && error && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'loading' && (
|
||||||
|
<div className="text-center text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
<p>请等待,正在验证您的登录信息...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'success' && (
|
||||||
|
<div className="text-center text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
<p>登录成功!即将跳转到首页...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'error' && (
|
||||||
|
<div className="text-center mt-4">
|
||||||
|
<button onClick={() => router.push('/')} className="text-blue-600 hover:text-blue-800 text-sm">
|
||||||
|
返回首页
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
Binary file not shown.
Before Width: | Height: | Size: 97 KiB |
|
@ -1,13 +1,172 @@
|
||||||
'use client';
|
'use client';
|
||||||
import { useTRPC } from '@repo/client';
|
|
||||||
import { useQuery } from '@tanstack/react-query';
|
|
||||||
import { useEffect } from 'react';
|
|
||||||
|
|
||||||
export default function Home() {
|
import { useAuth } from '@/providers/auth-provider';
|
||||||
const trpc = useTRPC();
|
import { UserProfile } from '@/components/user-profile';
|
||||||
const { data, isLoading } = useQuery(trpc.user.getUser.queryOptions());
|
import { LoginButton } from '@/components/login-button';
|
||||||
useEffect(() => {
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@repo/ui/components/card';
|
||||||
console.log(data);
|
import { Badge } from '@repo/ui/components/badge';
|
||||||
}, [data]);
|
import { Separator } from '@repo/ui/components/separator';
|
||||||
return <div>123</div>;
|
import { Shield, Key, Users, CheckCircle, Info } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function HomePage() {
|
||||||
|
const { isAuthenticated, isLoading, error } = useAuth();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 dark:from-gray-900 dark:to-gray-800">
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
{/* 页面标题 */}
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h1 className="text-4xl font-bold text-gray-900 dark:text-white mb-4">OIDC 认证演示</h1>
|
||||||
|
<p className="text-xl text-gray-600 dark:text-gray-300 max-w-2xl mx-auto">
|
||||||
|
基于 OpenID Connect 协议的安全认证系统演示
|
||||||
|
</p>
|
||||||
|
<div className="mt-4">
|
||||||
|
<a
|
||||||
|
href="/test-oidc"
|
||||||
|
className="inline-flex items-center px-4 py-2 text-sm font-medium text-blue-600 hover:text-blue-800 border border-blue-300 rounded-lg hover:bg-blue-50 dark:text-blue-400 dark:border-blue-600 dark:hover:bg-blue-900"
|
||||||
|
>
|
||||||
|
测试 OIDC 流程
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 功能特性卡片 */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<Shield className="h-8 w-8 text-blue-600 mb-2" />
|
||||||
|
<CardTitle className="text-lg">安全认证</CardTitle>
|
||||||
|
<CardDescription>基于 OAuth 2.0 和 OpenID Connect 标准的安全认证流程</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<Key className="h-8 w-8 text-green-600 mb-2" />
|
||||||
|
<CardTitle className="text-lg">Token 管理</CardTitle>
|
||||||
|
<CardDescription>自动管理访问令牌、刷新令牌和 ID 令牌的生命周期</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<Users className="h-8 w-8 text-purple-600 mb-2" />
|
||||||
|
<CardTitle className="text-lg">用户信息</CardTitle>
|
||||||
|
<CardDescription>获取和展示完整的用户配置文件信息</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 状态显示 */}
|
||||||
|
{error && (
|
||||||
|
<Card className="mb-6 border-red-200 bg-red-50 dark:border-red-800 dark:bg-red-900/20">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2 text-red-700 dark:text-red-300">
|
||||||
|
<Info className="h-5 w-5" />
|
||||||
|
<span className="font-medium">错误:</span>
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 主要内容区域 */}
|
||||||
|
<div className="flex flex-col items-center space-y-8">
|
||||||
|
{isLoading ? (
|
||||||
|
<Card className="w-full max-w-2xl">
|
||||||
|
<CardContent className="p-8">
|
||||||
|
<div className="animate-pulse space-y-4">
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-1/3 mx-auto"></div>
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-1/2 mx-auto"></div>
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-2/3 mx-auto"></div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : isAuthenticated ? (
|
||||||
|
<UserProfile />
|
||||||
|
) : (
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<CardTitle className="text-2xl">欢迎使用</CardTitle>
|
||||||
|
<CardDescription>请点击下方按钮开始 OIDC 认证流程</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col items-center space-y-4">
|
||||||
|
<LoginButton size="lg" className="w-full" />
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="text-sm text-gray-600 dark:text-gray-400 space-y-2">
|
||||||
|
<p className="font-medium">演示账户信息:</p>
|
||||||
|
<div className="bg-gray-100 dark:bg-gray-800 p-3 rounded-md">
|
||||||
|
<p>
|
||||||
|
用户名: <code className="text-sm">demouser</code>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
密码: <code className="text-sm">demo123</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 技术信息 */}
|
||||||
|
<Card className="w-full max-w-4xl">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<CheckCircle className="h-5 w-5 text-green-600" />
|
||||||
|
技术实现特性
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold mb-3">前端技术栈</h4>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Badge variant="outline">Next.js 15</Badge>
|
||||||
|
<Badge variant="outline">React 19</Badge>
|
||||||
|
<Badge variant="outline">TypeScript</Badge>
|
||||||
|
<Badge variant="outline">oidc-client-ts</Badge>
|
||||||
|
<Badge variant="outline">Tailwind CSS</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold mb-3">后端技术栈</h4>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Badge variant="outline">Hono</Badge>
|
||||||
|
<Badge variant="outline">OIDC Provider</Badge>
|
||||||
|
<Badge variant="outline">Redis</Badge>
|
||||||
|
<Badge variant="outline">JWT</Badge>
|
||||||
|
<Badge variant="outline">PKCE</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold mb-3">安全特性</h4>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Badge variant="secondary">授权码流程</Badge>
|
||||||
|
<Badge variant="secondary">PKCE 支持</Badge>
|
||||||
|
<Badge variant="secondary">Token 轮换</Badge>
|
||||||
|
<Badge variant="secondary">安全存储</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold mb-3">支持的作用域</h4>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Badge variant="default">openid</Badge>
|
||||||
|
<Badge variant="default">profile</Badge>
|
||||||
|
<Badge variant="default">email</Badge>
|
||||||
|
<Badge variant="default">phone</Badge>
|
||||||
|
<Badge variant="default">address</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 <Loader2 className="h-5 w-5 animate-spin text-blue-500" />;
|
||||||
|
case 'success':
|
||||||
|
return <CheckCircle className="h-5 w-5 text-green-500" />;
|
||||||
|
case 'error':
|
||||||
|
return <XCircle className="h-5 w-5 text-red-500" />;
|
||||||
|
default:
|
||||||
|
return <div className="h-5 w-5 rounded-full border-2 border-gray-300" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 dark:from-gray-900 dark:to-gray-800 p-8">
|
||||||
|
<div className="max-w-4xl mx-auto space-y-8">
|
||||||
|
{/* 页面标题 */}
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-4">OIDC 流程测试</h1>
|
||||||
|
<p className="text-lg text-gray-600 dark:text-gray-300">测试和验证 OpenID Connect 认证流程的各个环节</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* OIDC 流程步骤 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Shield className="h-5 w-5" />
|
||||||
|
标准 OIDC 认证流程
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>按照正确的 OIDC 架构,所有登录都在 OIDC Provider 中处理</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{/* 流程步骤图示 */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-5 gap-4 text-center">
|
||||||
|
<div className="flex flex-col items-center space-y-2">
|
||||||
|
<div className="w-12 h-12 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center">
|
||||||
|
<User className="h-6 w-6 text-blue-600 dark:text-blue-300" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium">用户点击登录</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<ArrowRight className="h-5 w-5 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center space-y-2">
|
||||||
|
<div className="w-12 h-12 bg-green-100 dark:bg-green-900 rounded-full flex items-center justify-center">
|
||||||
|
<Shield className="h-6 w-6 text-green-600 dark:text-green-300" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium">重定向到 Provider</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<ArrowRight className="h-5 w-5 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center space-y-2">
|
||||||
|
<div className="w-12 h-12 bg-purple-100 dark:bg-purple-900 rounded-full flex items-center justify-center">
|
||||||
|
<Key className="h-6 w-6 text-purple-600 dark:text-purple-300" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium">返回授权码</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 测试按钮 */}
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Button onClick={startOidcFlow} size="lg" className="px-8">
|
||||||
|
开始 OIDC 认证流程
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 提示信息 */}
|
||||||
|
<Alert>
|
||||||
|
<Shield className="h-4 w-4" />
|
||||||
|
<AlertDescription>
|
||||||
|
点击上方按钮将重定向到 OIDC Provider 的登录页面。
|
||||||
|
<br />
|
||||||
|
<strong>演示账号:</strong> demouser / demo123
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Discovery 端点测试 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
{getStatusIcon(testResults.discovery)}
|
||||||
|
Discovery 端点测试
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>测试 OIDC Provider 的配置发现端点</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<Button onClick={testDiscoveryEndpoint} disabled={testResults.discovery === 'loading'}>
|
||||||
|
{testResults.discovery === 'loading' ? '测试中...' : '测试 Discovery 端点'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{testResults.discovery === 'error' && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<XCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>
|
||||||
|
<strong>错误:</strong> {testResults.discoveryError}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{testResults.discovery === 'success' && testResults.discoveryData && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Alert>
|
||||||
|
<CheckCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>
|
||||||
|
<strong>成功!</strong> OIDC Provider 配置已获取
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<div className="bg-gray-50 dark:bg-gray-800 p-4 rounded-lg">
|
||||||
|
<h4 className="font-semibold mb-3">Provider 信息</h4>
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<p>
|
||||||
|
<strong>Issuer:</strong>{' '}
|
||||||
|
<code className="bg-gray-200 dark:bg-gray-700 px-1 rounded">
|
||||||
|
{testResults.discoveryData.issuer}
|
||||||
|
</code>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>授权端点:</strong>{' '}
|
||||||
|
<code className="bg-gray-200 dark:bg-gray-700 px-1 rounded">
|
||||||
|
{testResults.discoveryData.authorization_endpoint}
|
||||||
|
</code>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>令牌端点:</strong>{' '}
|
||||||
|
<code className="bg-gray-200 dark:bg-gray-700 px-1 rounded">
|
||||||
|
{testResults.discoveryData.token_endpoint}
|
||||||
|
</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold mb-3">支持的功能</h4>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{testResults.discoveryData.response_types_supported?.map((type: string) => (
|
||||||
|
<Badge key={type} variant="outline">
|
||||||
|
{type}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2 mt-2">
|
||||||
|
{testResults.discoveryData.scopes_supported?.map((scope: string) => (
|
||||||
|
<Badge key={scope} variant="secondary">
|
||||||
|
{scope}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 架构说明 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>正确的 OIDC 架构</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold text-green-600 mb-2">✅ 已实现</h4>
|
||||||
|
<ul className="space-y-1 text-sm">
|
||||||
|
<li>• OIDC Provider 包含登录页面</li>
|
||||||
|
<li>• 标准授权码流程</li>
|
||||||
|
<li>• PKCE 支持</li>
|
||||||
|
<li>• 内置会话管理</li>
|
||||||
|
<li>• 自动令牌刷新</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold text-red-600 mb-2">❌ 已移除</h4>
|
||||||
|
<ul className="space-y-1 text-sm">
|
||||||
|
<li>• 客户端应用的登录页面</li>
|
||||||
|
<li>• 自定义认证逻辑</li>
|
||||||
|
<li>• 重复的用户管理</li>
|
||||||
|
<li>• 混合认证流程</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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 (
|
||||||
|
<Button onClick={login} disabled={isLoading} variant={variant} size={size} className={className}>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
连接中...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<LogIn className="mr-2 h-4 w-4" />
|
||||||
|
登录
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
|
@ -3,6 +3,7 @@
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { ThemeProvider as NextThemesProvider } from 'next-themes';
|
import { ThemeProvider as NextThemesProvider } from 'next-themes';
|
||||||
import QueryProvider from '@/providers/query-provider';
|
import QueryProvider from '@/providers/query-provider';
|
||||||
|
import { AuthProvider } from '@/providers/auth-provider';
|
||||||
|
|
||||||
export function Providers({ children }: { children: React.ReactNode }) {
|
export function Providers({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
|
@ -13,7 +14,9 @@ export function Providers({ children }: { children: React.ReactNode }) {
|
||||||
disableTransitionOnChange
|
disableTransitionOnChange
|
||||||
enableColorScheme
|
enableColorScheme
|
||||||
>
|
>
|
||||||
<QueryProvider>{children}</QueryProvider>
|
<QueryProvider>
|
||||||
|
<AuthProvider>{children}</AuthProvider>
|
||||||
|
</QueryProvider>
|
||||||
</NextThemesProvider>
|
</NextThemesProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 (
|
||||||
|
<Card className="w-full max-w-2xl">
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="animate-pulse space-y-4">
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-1/3"></div>
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-1/2"></div>
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-2/3"></div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated || !user) {
|
||||||
|
return (
|
||||||
|
<Card className="w-full max-w-2xl">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>未登录</CardTitle>
|
||||||
|
<CardDescription>请先登录以查看用户信息</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = user.profile;
|
||||||
|
const formatDate = (timestamp?: number) => {
|
||||||
|
if (!timestamp) return '未知';
|
||||||
|
return new Date(timestamp * 1000).toLocaleString('zh-CN');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="w-full max-w-2xl">
|
||||||
|
<CardHeader className="pb-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<Avatar className="h-12 w-12">
|
||||||
|
<AvatarImage src={profile.picture} alt={profile.name} />
|
||||||
|
<AvatarFallback>{profile.name?.charAt(0) || profile.preferred_username?.charAt(0) || 'U'}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-xl">{profile.name || profile.preferred_username || '未知用户'}</CardTitle>
|
||||||
|
<CardDescription>用户ID: {profile.sub}</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" onClick={logout} className="gap-2">
|
||||||
|
<LogOut className="h-4 w-4" />
|
||||||
|
登出
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{/* 基本信息 */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold mb-3 flex items-center gap-2">
|
||||||
|
<User className="h-5 w-5" />
|
||||||
|
基本信息
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{profile.given_name && (
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-gray-600 dark:text-gray-400">名</label>
|
||||||
|
<p className="text-sm">{profile.given_name}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{profile.family_name && (
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-gray-600 dark:text-gray-400">姓</label>
|
||||||
|
<p className="text-sm">{profile.family_name}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{profile.nickname && (
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-gray-600 dark:text-gray-400">昵称</label>
|
||||||
|
<p className="text-sm">{profile.nickname}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{profile.gender && (
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-gray-600 dark:text-gray-400">性别</label>
|
||||||
|
<p className="text-sm">{profile.gender}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* 联系信息 */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold mb-3 flex items-center gap-2">
|
||||||
|
<Mail className="h-5 w-5" />
|
||||||
|
联系信息
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{profile.email && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-gray-600 dark:text-gray-400">邮箱</label>
|
||||||
|
<p className="text-sm">{profile.email}</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant={profile.email_verified ? 'default' : 'secondary'}>
|
||||||
|
{profile.email_verified ? '已验证' : '未验证'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{profile.phone_number && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-gray-600 dark:text-gray-400">电话</label>
|
||||||
|
<p className="text-sm">{profile.phone_number}</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant={profile.phone_number_verified ? 'default' : 'secondary'}>
|
||||||
|
{profile.phone_number_verified ? '已验证' : '未验证'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{profile.address && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold mb-3 flex items-center gap-2">
|
||||||
|
<MapPin className="h-5 w-5" />
|
||||||
|
地址信息
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{profile.address.formatted && (
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-gray-600 dark:text-gray-400">完整地址</label>
|
||||||
|
<p className="text-sm">{profile.address.formatted}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
{profile.address.country && (
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-gray-600 dark:text-gray-400">国家</label>
|
||||||
|
<p className="text-sm">{profile.address.country}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{profile.address.region && (
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-gray-600 dark:text-gray-400">省/州</label>
|
||||||
|
<p className="text-sm">{profile.address.region}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{profile.address.locality && (
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-gray-600 dark:text-gray-400">城市</label>
|
||||||
|
<p className="text-sm">{profile.address.locality}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{profile.address.postal_code && (
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-gray-600 dark:text-gray-400">邮编</label>
|
||||||
|
<p className="text-sm">{profile.address.postal_code}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* 其他信息 */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold mb-3 flex items-center gap-2">
|
||||||
|
<Globe className="h-5 w-5" />
|
||||||
|
其他信息
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{profile.birthdate && (
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-gray-600 dark:text-gray-400">生日</label>
|
||||||
|
<p className="text-sm">{profile.birthdate}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{profile.zoneinfo && (
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-gray-600 dark:text-gray-400">时区</label>
|
||||||
|
<p className="text-sm">{profile.zoneinfo}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{profile.locale && (
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-gray-600 dark:text-gray-400">语言</label>
|
||||||
|
<p className="text-sm">{profile.locale}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{profile.updated_at && (
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-gray-600 dark:text-gray-400">更新时间</label>
|
||||||
|
<p className="text-sm">{formatDate(profile.updated_at)}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Token 信息 */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold mb-3 flex items-center gap-2">
|
||||||
|
<Calendar className="h-5 w-5" />
|
||||||
|
Token 信息
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{user.expires_at && (
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-gray-600 dark:text-gray-400">访问令牌过期时间</label>
|
||||||
|
<p className="text-sm">{new Date(user.expires_at * 1000).toLocaleString('zh-CN')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-gray-600 dark:text-gray-400">Token类型</label>
|
||||||
|
<p className="text-sm">{user.token_type}</p>
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label className="text-sm font-medium text-gray-600 dark:text-gray-400">作用域</label>
|
||||||
|
<div className="flex flex-wrap gap-1 mt-1">
|
||||||
|
{user.scope?.split(' ').map((scope) => (
|
||||||
|
<Badge key={scope} variant="outline" className="text-xs">
|
||||||
|
{scope}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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`,
|
||||||
|
};
|
|
@ -4,12 +4,16 @@
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"dev": "next dev --turbopack -p 3001",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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/client": "workspace:*",
|
||||||
"@repo/db": "workspace:*",
|
"@repo/db": "workspace:*",
|
||||||
"@repo/ui": "workspace:*",
|
"@repo/ui": "workspace:*",
|
||||||
|
@ -23,15 +27,16 @@
|
||||||
"lucide-react": "0.511.0",
|
"lucide-react": "0.511.0",
|
||||||
"next": "15.3.2",
|
"next": "15.3.2",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
"oidc-client-ts": "^3.2.1",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"superjson": "^2.2.2"
|
"superjson": "^2.2.2",
|
||||||
|
"valibot": "^1.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@repo/eslint-config": "workspace:*",
|
"@repo/eslint-config": "workspace:*",
|
||||||
"@repo/typescript-config": "workspace:*",
|
"@repo/typescript-config": "workspace:*",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
|
||||||
"@types/react": "^19.1.4",
|
"@types/react": "^19.1.4",
|
||||||
"@types/react-dom": "^19.1.5",
|
"@types/react-dom": "^19.1.5",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
|
|
|
@ -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<void>;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export const useAuth = () => {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useAuth必须在AuthProvider内部使用');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface AuthProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const isAuthenticated = !!user && !user.expired;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!userManager) return;
|
||||||
|
|
||||||
|
const initAuth = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
const currentUser = await userManager.getUser();
|
||||||
|
setUser(currentUser);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('初始化认证失败:', err);
|
||||||
|
setError('认证初始化失败');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
initAuth();
|
||||||
|
|
||||||
|
// 监听用户状态变化
|
||||||
|
const handleUserLoaded = (user: User) => {
|
||||||
|
setUser(user);
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUserUnloaded = () => {
|
||||||
|
setUser(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAccessTokenExpired = () => {
|
||||||
|
setUser(null);
|
||||||
|
setError('访问令牌已过期');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSilentRenewError = (error: Error) => {
|
||||||
|
console.error('静默续约失败:', error);
|
||||||
|
setError('令牌续约失败');
|
||||||
|
};
|
||||||
|
|
||||||
|
userManager.events.addUserLoaded(handleUserLoaded);
|
||||||
|
userManager.events.addUserUnloaded(handleUserUnloaded);
|
||||||
|
userManager.events.addAccessTokenExpired(handleAccessTokenExpired);
|
||||||
|
userManager.events.addSilentRenewError(handleSilentRenewError);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (userManager) {
|
||||||
|
userManager.events.removeUserLoaded(handleUserLoaded);
|
||||||
|
userManager.events.removeUserUnloaded(handleUserUnloaded);
|
||||||
|
userManager.events.removeAccessTokenExpired(handleAccessTokenExpired);
|
||||||
|
userManager.events.removeSilentRenewError(handleSilentRenewError);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const login = async () => {
|
||||||
|
if (!userManager) {
|
||||||
|
setError('用户管理器未初始化');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setError(null);
|
||||||
|
await userManager.signinRedirect();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('登录失败:', err);
|
||||||
|
setError('登录失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = async () => {
|
||||||
|
if (!userManager) {
|
||||||
|
setError('用户管理器未初始化');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setError(null);
|
||||||
|
await userManager.signoutRedirect();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('登出失败:', err);
|
||||||
|
setError('登出失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const value: AuthContextType = {
|
||||||
|
user,
|
||||||
|
isLoading,
|
||||||
|
isAuthenticated,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
error,
|
||||||
|
};
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
};
|
|
@ -3,15 +3,12 @@
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts"
|
".": "./src/index.ts"
|
||||||
},
|
},
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
"files": [
|
"files": [
|
||||||
"dist",
|
"dist",
|
||||||
"src"
|
"src"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
|
||||||
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@tanstack/query-async-storage-persister": "^5.51.9",
|
"@tanstack/query-async-storage-persister": "^5.51.9",
|
||||||
"@tanstack/react-query": "^5.51.21",
|
"@tanstack/react-query": "^5.51.21",
|
||||||
|
@ -25,9 +22,9 @@
|
||||||
"react": "^19.1.0"
|
"react": "^19.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"rimraf": "^6.0.1",
|
|
||||||
"tsup": "^8.3.5",
|
|
||||||
"@types/react": "^19.1.4",
|
"@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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"
|
||||||
|
}
|
|
@ -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<string | null>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 认证管理器配置
|
||||||
|
*/
|
||||||
|
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<string | null> {
|
||||||
|
return await this.passwordAuth.getCurrentUser(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理认证要求
|
||||||
|
*/
|
||||||
|
async handleAuthenticationRequired(
|
||||||
|
c: Context,
|
||||||
|
authRequest: AuthorizationRequest
|
||||||
|
): Promise<Response> {
|
||||||
|
return await this.passwordAuth.handleAuthenticationRequired(c, authRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理认证请求
|
||||||
|
*/
|
||||||
|
async authenticate(c: Context): Promise<AuthenticationResult> {
|
||||||
|
return await this.passwordAuth.authenticate(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理登出请求
|
||||||
|
*/
|
||||||
|
async logout(c: Context): Promise<Response> {
|
||||||
|
return await this.passwordAuth.logout(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理完整的登录流程
|
||||||
|
*/
|
||||||
|
async handleLogin(c: Context, authRequest: AuthorizationRequest): Promise<Response> {
|
||||||
|
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<Response> {
|
||||||
|
return await this.passwordAuth.handleAuthenticationSuccess(c, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理认证失败
|
||||||
|
*/
|
||||||
|
private async handleAuthenticationFailure(
|
||||||
|
c: Context,
|
||||||
|
result: AuthenticationResult,
|
||||||
|
authRequest: AuthorizationRequest
|
||||||
|
): Promise<Response> {
|
||||||
|
return await this.passwordAuth.handleAuthenticationFailure(c, result, authRequest);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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';
|
|
@ -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<SessionData> {
|
||||||
|
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<void> {
|
||||||
|
await this.deleteSession(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async storeSession(sessionId: string, data: any): Promise<void> {
|
||||||
|
const key = this.getSessionKey(sessionId);
|
||||||
|
await this.storage.set(key, data, this.sessionTTL);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSession(sessionId: string): Promise<any> {
|
||||||
|
const key = this.getSessionKey(sessionId);
|
||||||
|
return await this.storage.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteSession(sessionId: string): Promise<void> {
|
||||||
|
const key = this.getSessionKey(sessionId);
|
||||||
|
await this.storage.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateSession(sessionId: string, updates: any): Promise<void> {
|
||||||
|
const existingSession = await this.getSession(sessionId);
|
||||||
|
if (existingSession) {
|
||||||
|
const updatedSession = { ...existingSession, ...updates };
|
||||||
|
await this.storeSession(sessionId, updatedSession);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 密码认证策略配置
|
||||||
|
*/
|
||||||
|
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<string | null> {
|
||||||
|
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<Response> {
|
||||||
|
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<AuthenticationResult> {
|
||||||
|
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<Response> {
|
||||||
|
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<Response> {
|
||||||
|
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<Response> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<void> {
|
||||||
|
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<AuthorizationCode | null> {
|
||||||
|
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<void> {
|
||||||
|
const key = this.getTokenKey('auth_code', code);
|
||||||
|
await this.storage.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 访问令牌管理
|
||||||
|
async storeAccessToken(token: AccessToken): Promise<void> {
|
||||||
|
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<string[]>(userClientKey) || [];
|
||||||
|
existingTokens.push(token.token);
|
||||||
|
await this.storage.set(userClientKey, existingTokens, Math.max(ttl, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAccessToken(token: string): Promise<AccessToken | null> {
|
||||||
|
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<void> {
|
||||||
|
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<string[]>(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<void> {
|
||||||
|
const userClientKey = this.getUserClientKey('access_tokens', userId, clientId);
|
||||||
|
const tokens = await this.storage.get<string[]>(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<void> {
|
||||||
|
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<string[]>(userClientKey) || [];
|
||||||
|
existingTokens.push(token.token);
|
||||||
|
await this.storage.set(userClientKey, existingTokens, Math.max(ttl, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRefreshToken(token: string): Promise<RefreshToken | null> {
|
||||||
|
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<void> {
|
||||||
|
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<string[]>(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<void> {
|
||||||
|
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<IDToken | null> {
|
||||||
|
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<void> {
|
||||||
|
const key = this.getTokenKey('id_token', token);
|
||||||
|
await this.storage.delete(key);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<CookieConfig> = {}): 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<string, string> {
|
||||||
|
const cookieHeader = c.req.header('Cookie');
|
||||||
|
if (!cookieHeader) return {};
|
||||||
|
|
||||||
|
const cookies: Record<string, string> = {};
|
||||||
|
|
||||||
|
cookieHeader.split(';').forEach(cookie => {
|
||||||
|
const [name, ...rest] = cookie.trim().split('=');
|
||||||
|
if (name && rest.length > 0) {
|
||||||
|
cookies[name] = decodeURIComponent(rest.join('='));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return cookies;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>${this.escapeHtml(title)}</title>
|
||||||
|
<style>
|
||||||
|
${this.getDefaultStyles()}
|
||||||
|
${customCSS}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="login-container">
|
||||||
|
<div class="login-header">
|
||||||
|
${logoUrl ? `<img src="${this.escapeHtml(logoUrl)}" alt="Logo" class="logo" />` : ''}
|
||||||
|
<h1>${this.escapeHtml(brandName)}</h1>
|
||||||
|
<p>请登录以继续访问应用</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="login-form" method="POST" action="/oidc/login">
|
||||||
|
${error ? `<div class="error-message">${this.escapeHtml(error)}</div>` : ''}
|
||||||
|
|
||||||
|
<div class="client-info">
|
||||||
|
<h3>应用授权</h3>
|
||||||
|
<p>应用 <strong>${this.escapeHtml(authRequest.client_id)}</strong> 请求访问您的账户信息</p>
|
||||||
|
<p class="scope-info">权限范围: <code>${this.escapeHtml(authRequest.scope)}</code></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="username">用户名</label>
|
||||||
|
<input type="text" id="username" name="username" required autocomplete="username" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">密码</label>
|
||||||
|
<input type="password" id="password" name="password" required autocomplete="current-password" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="checkbox-group">
|
||||||
|
<input type="checkbox" id="remember_me" name="remember_me" />
|
||||||
|
<label for="remember_me">记住我</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${this.generateHiddenFields(authRequest)}
|
||||||
|
|
||||||
|
<button type="submit" class="login-button">登录</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成错误页面HTML
|
||||||
|
*/
|
||||||
|
static generateErrorPage(
|
||||||
|
error: string,
|
||||||
|
description?: string,
|
||||||
|
config: PageConfig = {}
|
||||||
|
): string {
|
||||||
|
const {
|
||||||
|
title = '认证错误',
|
||||||
|
brandName = this.DEFAULT_BRAND_NAME
|
||||||
|
} = config;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>${this.escapeHtml(title)}</title>
|
||||||
|
<style>
|
||||||
|
${this.getDefaultStyles()}
|
||||||
|
.error-container {
|
||||||
|
max-width: 500px;
|
||||||
|
margin: 2rem auto;
|
||||||
|
padding: 2rem;
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.error-icon {
|
||||||
|
font-size: 3rem;
|
||||||
|
color: #dc2626;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="error-container">
|
||||||
|
<div class="error-icon">⚠️</div>
|
||||||
|
<h1>${this.escapeHtml(brandName)}</h1>
|
||||||
|
<h2>认证失败</h2>
|
||||||
|
<div class="error-message">
|
||||||
|
<strong>${this.escapeHtml(error)}</strong>
|
||||||
|
${description ? `<p>${this.escapeHtml(description)}</p>` : ''}
|
||||||
|
</div>
|
||||||
|
<button onclick="history.back()" class="login-button" style="margin-top: 1rem;">返回</button>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取默认样式
|
||||||
|
*/
|
||||||
|
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 => `<input type="hidden" name="${this.escapeHtml(field.name)}" value="${this.escapeHtml(field.value)}" />`)
|
||||||
|
.join('\n ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转义HTML字符
|
||||||
|
*/
|
||||||
|
private static escapeHtml(unsafe: string): string {
|
||||||
|
return unsafe
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
}
|
|
@ -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';
|
|
@ -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');
|
||||||
|
}
|
||||||
|
|
|
@ -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<OIDCUser | null>;
|
||||||
|
private findClient: (clientId: string) => Promise<OIDCClient | null>;
|
||||||
|
|
||||||
|
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<AuthorizationRequest>,
|
||||||
|
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<TokenRequest>
|
||||||
|
): 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<TokenRequest>,
|
||||||
|
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<TokenRequest>,
|
||||||
|
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<OIDCUser> } | { 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<OIDCUser> {
|
||||||
|
const filtered: Partial<OIDCUser> = {};
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,44 @@
|
||||||
|
/**
|
||||||
|
* 通用存储适配器接口
|
||||||
|
* 使用简单的key-value存储模式支持所有OIDC存储需求
|
||||||
|
*/
|
||||||
|
export interface StorageAdapter {
|
||||||
|
/**
|
||||||
|
* 存储数据
|
||||||
|
* @param key 存储键
|
||||||
|
* @param value 存储值
|
||||||
|
* @param ttl 过期时间(秒),可选
|
||||||
|
*/
|
||||||
|
set(key: string, value: any, ttl?: number): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取数据
|
||||||
|
* @param key 存储键
|
||||||
|
*/
|
||||||
|
get<T = any>(key: string): Promise<T | null>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除数据
|
||||||
|
* @param key 存储键
|
||||||
|
*/
|
||||||
|
delete(key: string): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量删除匹配模式的键
|
||||||
|
* @param pattern 键模式(支持通配符)
|
||||||
|
*/
|
||||||
|
deletePattern(pattern: string): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查键是否存在
|
||||||
|
* @param key 存储键
|
||||||
|
*/
|
||||||
|
exists(key: string): Promise<boolean>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置键的过期时间
|
||||||
|
* @param key 存储键
|
||||||
|
* @param ttl 过期时间(秒)
|
||||||
|
*/
|
||||||
|
expire(key: string, ttl: number): Promise<void>;
|
||||||
|
}
|
|
@ -0,0 +1,2 @@
|
||||||
|
export type { StorageAdapter } from './adapter';
|
||||||
|
export { RedisStorageAdapter } from './redis-adapter';
|
|
@ -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<void> {
|
||||||
|
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<T = any>(key: string): Promise<T | null> {
|
||||||
|
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<void> {
|
||||||
|
const redisKey = this.getKey(key);
|
||||||
|
await this.redis.del(redisKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deletePattern(pattern: string): Promise<void> {
|
||||||
|
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<boolean> {
|
||||||
|
const redisKey = this.getKey(key);
|
||||||
|
const result = await this.redis.exists(redisKey);
|
||||||
|
return result === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
async expire(key: string, ttl: number): Promise<void> {
|
||||||
|
const redisKey = this.getKey(key);
|
||||||
|
await this.redis.expire(redisKey, ttl);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<OIDCUser | null>;
|
||||||
|
/** 获取客户端的回调函数 */
|
||||||
|
findClient: (clientId: string) => Promise<OIDCClient | null>;
|
||||||
|
/** 令牌过期时间配置 */
|
||||||
|
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<string | null>;
|
|
@ -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<string> {
|
||||||
|
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<string> {
|
||||||
|
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<JWTPayload> {
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<AuthorizationRequest>,
|
||||||
|
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<TokenRequest>,
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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"
|
||||||
|
]
|
||||||
|
}
|
|
@ -14,8 +14,12 @@
|
||||||
"lint": "eslint . --max-warnings 0"
|
"lint": "eslint . --max-warnings 0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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-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",
|
"clsx": "^2.1.1",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
|
@ -23,13 +27,13 @@
|
||||||
"zod": "^3.24.4"
|
"zod": "^3.24.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@repo/eslint-config": "workspace:*",
|
||||||
|
"@repo/typescript-config": "workspace:*",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@turbo/gen": "^2.5.3",
|
"@turbo/gen": "^2.5.3",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19.1.4",
|
"@types/react": "^19.1.4",
|
||||||
"@types/react-dom": "^19.1.5",
|
"@types/react-dom": "^19.1.5",
|
||||||
"@repo/eslint-config": "workspace:*",
|
|
||||||
"@repo/typescript-config": "workspace:*",
|
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"lucide-react": "0.511.0",
|
"lucide-react": "0.511.0",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
|
|
|
@ -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<typeof alertVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert"
|
||||||
|
role="alert"
|
||||||
|
className={cn(alertVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-title"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-description"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Alert, AlertTitle, AlertDescription }
|
|
@ -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<typeof AvatarPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Root
|
||||||
|
data-slot="avatar"
|
||||||
|
className={cn(
|
||||||
|
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarImage({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Image
|
||||||
|
data-slot="avatar-image"
|
||||||
|
className={cn("aspect-square size-full", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarFallback({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Fallback
|
||||||
|
data-slot="avatar-fallback"
|
||||||
|
className={cn(
|
||||||
|
"bg-muted flex size-full items-center justify-center rounded-full",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Avatar, AvatarImage, AvatarFallback }
|
|
@ -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<typeof badgeVariants> & { asChild?: boolean }) {
|
||||||
|
const Comp = asChild ? Slot : "span"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="badge"
|
||||||
|
className={cn(badgeVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
|
@ -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 <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
||||||
|
return (
|
||||||
|
<ol
|
||||||
|
data-slot="breadcrumb-list"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
data-slot="breadcrumb-item"
|
||||||
|
className={cn("inline-flex items-center gap-1.5", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbLink({
|
||||||
|
asChild,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"a"> & {
|
||||||
|
asChild?: boolean
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot : "a"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="breadcrumb-link"
|
||||||
|
className={cn("hover:text-foreground transition-colors", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="breadcrumb-page"
|
||||||
|
role="link"
|
||||||
|
aria-disabled="true"
|
||||||
|
aria-current="page"
|
||||||
|
className={cn("text-foreground font-normal", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbSeparator({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"li">) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
data-slot="breadcrumb-separator"
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn("[&>svg]:size-3.5", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children ?? <ChevronRight />}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbEllipsis({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="breadcrumb-ellipsis"
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn("flex size-9 items-center justify-center", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="size-4" />
|
||||||
|
<span className="sr-only">More</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbList,
|
||||||
|
BreadcrumbItem,
|
||||||
|
BreadcrumbLink,
|
||||||
|
BreadcrumbPage,
|
||||||
|
BreadcrumbSeparator,
|
||||||
|
BreadcrumbEllipsis,
|
||||||
|
}
|
|
@ -1,48 +1,59 @@
|
||||||
import type * as React from 'react';
|
import * as React from "react"
|
||||||
import { Slot } from '@radix-ui/react-slot';
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
import { cva, type VariantProps } from 'class-variance-authority';
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
import { cn } from '@repo/ui/lib/utils';
|
import { cn } from "@repo/ui/lib/utils"
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[color,box-shadow] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 focus-visible:ring-4 focus-visible:outline-1 aria-invalid:focus-visible:ring-0",
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-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",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: 'bg-primary text-primary-foreground shadow-sm hover:bg-primary/90',
|
default:
|
||||||
destructive: 'bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90',
|
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||||
outline: 'border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground',
|
destructive:
|
||||||
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
|
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
outline:
|
||||||
link: 'text-primary underline-offset-4 hover:underline',
|
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||||
},
|
secondary:
|
||||||
size: {
|
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||||
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
ghost:
|
||||||
sm: 'h-8 rounded-md px-3 has-[>svg]:px-2.5',
|
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||||
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
icon: 'size-9',
|
},
|
||||||
},
|
size: {
|
||||||
},
|
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||||
defaultVariants: {
|
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||||
variant: 'default',
|
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||||
size: 'default',
|
icon: "size-9",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
function Button({
|
function Button({
|
||||||
className,
|
className,
|
||||||
variant,
|
variant,
|
||||||
size,
|
size,
|
||||||
asChild = false,
|
asChild = false,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<'button'> &
|
}: React.ComponentProps<"button"> &
|
||||||
VariantProps<typeof buttonVariants> & {
|
VariantProps<typeof buttonVariants> & {
|
||||||
asChild?: boolean;
|
asChild?: boolean
|
||||||
}) {
|
}) {
|
||||||
const Comp = asChild ? Slot : 'button';
|
const Comp = asChild ? Slot : "button"
|
||||||
|
|
||||||
return <Comp data-slot="button" className={cn(buttonVariants({ variant, size, className }))} {...props} />;
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="button"
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Button, buttonVariants };
|
export { Button, buttonVariants }
|
||||||
|
|
|
@ -0,0 +1,92 @@
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@repo/ui/lib/utils"
|
||||||
|
|
||||||
|
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card"
|
||||||
|
className={cn(
|
||||||
|
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-header"
|
||||||
|
className={cn(
|
||||||
|
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-title"
|
||||||
|
className={cn("leading-none font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-action"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-content"
|
||||||
|
className={cn("px-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-footer"
|
||||||
|
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardFooter,
|
||||||
|
CardTitle,
|
||||||
|
CardAction,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
}
|
|
@ -0,0 +1,135 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||||
|
import { XIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@repo/ui/lib/utils"
|
||||||
|
|
||||||
|
function Dialog({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||||
|
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||||
|
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||||
|
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogClose({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||||
|
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
data-slot="dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<DialogPortal data-slot="dialog-portal">
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
data-slot="dialog-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
|
||||||
|
<XIcon />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-header"
|
||||||
|
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
data-slot="dialog-title"
|
||||||
|
className={cn("text-lg leading-none font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
data-slot="dialog-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
}
|
|
@ -0,0 +1,21 @@
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@repo/ui/lib/utils"
|
||||||
|
|
||||||
|
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
data-slot="input"
|
||||||
|
className={cn(
|
||||||
|
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
"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",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Input }
|
|
@ -0,0 +1,24 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||||
|
|
||||||
|
import { cn } from "@repo/ui/lib/utils"
|
||||||
|
|
||||||
|
function Label({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
data-slot="label"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Label }
|
|
@ -0,0 +1,28 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||||
|
|
||||||
|
import { cn } from "@repo/ui/lib/utils"
|
||||||
|
|
||||||
|
function Separator({
|
||||||
|
className,
|
||||||
|
orientation = "horizontal",
|
||||||
|
decorative = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
data-slot="separator-root"
|
||||||
|
decorative={decorative}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Separator }
|
673
pnpm-lock.yaml
673
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1 @@
|
||||||
|
|
Loading…
Reference in New Issue