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:
|
||||
```sh
|
||||
bun install
|
||||
# OIDC Provider Demo
|
||||
|
||||
这是一个基于 Hono 后端的 OpenID Connect (OIDC) Provider 演示应用,使用了标准的OIDC架构设计。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- ✅ 完整的 OIDC Provider 实现
|
||||
- ✅ 支持授权码流程 (Authorization Code Flow)
|
||||
- ✅ 支持 PKCE (Proof Key for Code Exchange)
|
||||
- ✅ 内置认证处理器
|
||||
- ✅ JWT 令牌签发和验证
|
||||
- ✅ 用户信息端点
|
||||
- ✅ 令牌撤销
|
||||
- ✅ Redis 存储适配器
|
||||
- ✅ 内置登录页面
|
||||
|
||||
## 标准OIDC架构
|
||||
|
||||
### 使用内置认证处理器(推荐)
|
||||
|
||||
```typescript
|
||||
import { createOIDCProvider } from '@repo/oidc-provider';
|
||||
|
||||
// 使用内置认证处理器,Provider自己处理登录
|
||||
const oidcApp = createOIDCProvider({
|
||||
config: oidcConfig,
|
||||
useBuiltInAuth: true,
|
||||
builtInAuthConfig: {
|
||||
passwordValidator: validatePassword,
|
||||
sessionTTL: 24 * 60 * 60, // 24小时
|
||||
loginPageTitle: 'OIDC Demo 登录',
|
||||
brandName: 'OIDC Demo Provider',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
To run:
|
||||
```sh
|
||||
### 自定义认证处理器(高级用法)
|
||||
|
||||
```typescript
|
||||
import { createOIDCProvider, type AuthHandler } from '@repo/oidc-provider';
|
||||
|
||||
class MyAuthHandler implements AuthHandler {
|
||||
async getCurrentUser(c: Context): Promise<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
|
||||
```
|
||||
|
||||
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": {
|
||||
"@elastic/elasticsearch": "^9.0.2",
|
||||
"@hono/node-server": "^1.14.3",
|
||||
"@hono/trpc-server": "^0.3.4",
|
||||
"@hono/zod-validator": "^0.5.0",
|
||||
"@repo/db": "workspace:*",
|
||||
"@repo/oidc-provider": "workspace:*",
|
||||
"@trpc/server": "11.1.2",
|
||||
"@types/oidc-provider": "^9.1.0",
|
||||
"hono": "^4.7.10",
|
||||
"ioredis": "5.4.1",
|
||||
"jose": "^6.0.11",
|
||||
|
@ -18,10 +19,14 @@
|
|||
"node-cron": "^4.0.7",
|
||||
"oidc-provider": "^9.1.1",
|
||||
"superjson": "^2.2.2",
|
||||
"valibot": "^1.1.0",
|
||||
"zod": "^3.25.23"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"@types/node": "^22.15.21"
|
||||
"@types/node": "^22.15.21",
|
||||
"@types/oidc-provider": "^9.1.0",
|
||||
"supertest": "^7.1.1",
|
||||
"vitest": "^3.1.4"
|
||||
}
|
||||
}
|
|
@ -1,17 +1,16 @@
|
|||
import { Hono } from 'hono'
|
||||
import { logger } from 'hono/logger'
|
||||
import { contextStorage, getContext } from 'hono/context-storage'
|
||||
import { prettyJSON } from 'hono/pretty-json'
|
||||
import { cors } from 'hono/cors'
|
||||
|
||||
import { trpcServer } from '@hono/trpc-server'
|
||||
|
||||
import Redis from 'ioredis'
|
||||
import redis from './redis'
|
||||
import minioClient from './minio'
|
||||
import { Client } from 'minio'
|
||||
import oidc from './oidc/provider'
|
||||
import { appRouter } from './trpc'
|
||||
import { oidcApp } from './oidc-demo'
|
||||
|
||||
type Env = {
|
||||
Variables: {
|
||||
redis: Redis
|
||||
|
@ -21,23 +20,25 @@ type Env = {
|
|||
|
||||
const app = new Hono<Env>()
|
||||
|
||||
// 全局CORS配置
|
||||
app.use('*', cors({
|
||||
origin: 'http://localhost:3001',
|
||||
credentials: true,
|
||||
origin: '*',
|
||||
}))
|
||||
|
||||
// 注入依赖
|
||||
app.use('*', async (c, next) => {
|
||||
c.set('redis', redis)
|
||||
c.set('minio', minioClient)
|
||||
await next()
|
||||
})
|
||||
app.use('*', async (c, next) => {
|
||||
c.set('redis', redis);
|
||||
await next();
|
||||
});
|
||||
|
||||
// 中间件
|
||||
app.use(contextStorage())
|
||||
app.use(prettyJSON()) // With options: prettyJSON({ space: 4 })
|
||||
app.use(logger())
|
||||
app.use(prettyJSON())
|
||||
|
||||
app.route('/oidc', oidcApp)
|
||||
|
||||
// 挂载tRPC
|
||||
app.use(
|
||||
'/trpc/*',
|
||||
trpcServer({
|
||||
|
@ -45,10 +46,11 @@ app.use(
|
|||
})
|
||||
)
|
||||
|
||||
app.use('/oidc/*', async (c, next) => {
|
||||
// @ts-ignore
|
||||
await oidc.callback(c.req.raw, c.res.raw)
|
||||
// return void 也可以
|
||||
return
|
||||
})
|
||||
export default app
|
||||
// 启动服务器
|
||||
const port = parseInt(process.env.PORT || '3000');
|
||||
export default {
|
||||
port,
|
||||
fetch: app.fetch,
|
||||
}
|
||||
|
||||
console.log(`🚀 服务器运行在 http://localhost:${port}`)
|
||||
|
|
|
@ -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({
|
||||
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';
|
||||
import { useTRPC } from '@repo/client';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export default function Home() {
|
||||
const trpc = useTRPC();
|
||||
const { data, isLoading } = useQuery(trpc.user.getUser.queryOptions());
|
||||
useEffect(() => {
|
||||
console.log(data);
|
||||
}, [data]);
|
||||
return <div>123</div>;
|
||||
import { useAuth } from '@/providers/auth-provider';
|
||||
import { UserProfile } from '@/components/user-profile';
|
||||
import { LoginButton } from '@/components/login-button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@repo/ui/components/card';
|
||||
import { Badge } from '@repo/ui/components/badge';
|
||||
import { Separator } from '@repo/ui/components/separator';
|
||||
import { Shield, Key, Users, CheckCircle, Info } from 'lucide-react';
|
||||
|
||||
export default function HomePage() {
|
||||
const { isAuthenticated, isLoading, error } = useAuth();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 dark:from-gray-900 dark:to-gray-800">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
{/* 页面标题 */}
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-4xl font-bold text-gray-900 dark:text-white mb-4">OIDC 认证演示</h1>
|
||||
<p className="text-xl text-gray-600 dark:text-gray-300 max-w-2xl mx-auto">
|
||||
基于 OpenID Connect 协议的安全认证系统演示
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<a
|
||||
href="/test-oidc"
|
||||
className="inline-flex items-center px-4 py-2 text-sm font-medium text-blue-600 hover:text-blue-800 border border-blue-300 rounded-lg hover:bg-blue-50 dark:text-blue-400 dark:border-blue-600 dark:hover:bg-blue-900"
|
||||
>
|
||||
测试 OIDC 流程
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 功能特性卡片 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Shield className="h-8 w-8 text-blue-600 mb-2" />
|
||||
<CardTitle className="text-lg">安全认证</CardTitle>
|
||||
<CardDescription>基于 OAuth 2.0 和 OpenID Connect 标准的安全认证流程</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Key className="h-8 w-8 text-green-600 mb-2" />
|
||||
<CardTitle className="text-lg">Token 管理</CardTitle>
|
||||
<CardDescription>自动管理访问令牌、刷新令牌和 ID 令牌的生命周期</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Users className="h-8 w-8 text-purple-600 mb-2" />
|
||||
<CardTitle className="text-lg">用户信息</CardTitle>
|
||||
<CardDescription>获取和展示完整的用户配置文件信息</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 状态显示 */}
|
||||
{error && (
|
||||
<Card className="mb-6 border-red-200 bg-red-50 dark:border-red-800 dark:bg-red-900/20">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 text-red-700 dark:text-red-300">
|
||||
<Info className="h-5 w-5" />
|
||||
<span className="font-medium">错误:</span>
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 主要内容区域 */}
|
||||
<div className="flex flex-col items-center space-y-8">
|
||||
{isLoading ? (
|
||||
<Card className="w-full max-w-2xl">
|
||||
<CardContent className="p-8">
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-4 bg-gray-200 rounded w-1/3 mx-auto"></div>
|
||||
<div className="h-4 bg-gray-200 rounded w-1/2 mx-auto"></div>
|
||||
<div className="h-4 bg-gray-200 rounded w-2/3 mx-auto"></div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : isAuthenticated ? (
|
||||
<UserProfile />
|
||||
) : (
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-2xl">欢迎使用</CardTitle>
|
||||
<CardDescription>请点击下方按钮开始 OIDC 认证流程</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col items-center space-y-4">
|
||||
<LoginButton size="lg" className="w-full" />
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 space-y-2">
|
||||
<p className="font-medium">演示账户信息:</p>
|
||||
<div className="bg-gray-100 dark:bg-gray-800 p-3 rounded-md">
|
||||
<p>
|
||||
用户名: <code className="text-sm">demouser</code>
|
||||
</p>
|
||||
<p>
|
||||
密码: <code className="text-sm">demo123</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 技术信息 */}
|
||||
<Card className="w-full max-w-4xl">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<CheckCircle className="h-5 w-5 text-green-600" />
|
||||
技术实现特性
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h4 className="font-semibold mb-3">前端技术栈</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="outline">Next.js 15</Badge>
|
||||
<Badge variant="outline">React 19</Badge>
|
||||
<Badge variant="outline">TypeScript</Badge>
|
||||
<Badge variant="outline">oidc-client-ts</Badge>
|
||||
<Badge variant="outline">Tailwind CSS</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-semibold mb-3">后端技术栈</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="outline">Hono</Badge>
|
||||
<Badge variant="outline">OIDC Provider</Badge>
|
||||
<Badge variant="outline">Redis</Badge>
|
||||
<Badge variant="outline">JWT</Badge>
|
||||
<Badge variant="outline">PKCE</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-semibold mb-3">安全特性</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="secondary">授权码流程</Badge>
|
||||
<Badge variant="secondary">PKCE 支持</Badge>
|
||||
<Badge variant="secondary">Token 轮换</Badge>
|
||||
<Badge variant="secondary">安全存储</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-semibold mb-3">支持的作用域</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="default">openid</Badge>
|
||||
<Badge variant="default">profile</Badge>
|
||||
<Badge variant="default">email</Badge>
|
||||
<Badge variant="default">phone</Badge>
|
||||
<Badge variant="default">address</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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 { ThemeProvider as NextThemesProvider } from 'next-themes';
|
||||
import QueryProvider from '@/providers/query-provider';
|
||||
import { AuthProvider } from '@/providers/auth-provider';
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
|
@ -13,7 +14,9 @@ export function Providers({ children }: { children: React.ReactNode }) {
|
|||
disableTransitionOnChange
|
||||
enableColorScheme
|
||||
>
|
||||
<QueryProvider>{children}</QueryProvider>
|
||||
<QueryProvider>
|
||||
<AuthProvider>{children}</AuthProvider>
|
||||
</QueryProvider>
|
||||
</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",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"dev": "next dev --turbopack -p 3001",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@repo/client": "workspace:*",
|
||||
"@repo/db": "workspace:*",
|
||||
"@repo/ui": "workspace:*",
|
||||
|
@ -23,15 +27,16 @@
|
|||
"lucide-react": "0.511.0",
|
||||
"next": "15.3.2",
|
||||
"next-themes": "^0.4.6",
|
||||
"oidc-client-ts": "^3.2.1",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"superjson": "^2.2.2"
|
||||
"superjson": "^2.2.2",
|
||||
"valibot": "^1.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/eslint-config": "workspace:*",
|
||||
"@repo/typescript-config": "workspace:*",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
|
||||
"@types/react": "^19.1.4",
|
||||
"@types/react-dom": "^19.1.5",
|
||||
"tailwindcss": "^4",
|
||||
|
|
|
@ -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>;
|
||||
};
|
|
@ -9,9 +9,6 @@
|
|||
"dist",
|
||||
"src"
|
||||
],
|
||||
"dependencies": {
|
||||
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tanstack/query-async-storage-persister": "^5.51.9",
|
||||
"@tanstack/react-query": "^5.51.21",
|
||||
|
@ -25,9 +22,9 @@
|
|||
"react": "^19.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"rimraf": "^6.0.1",
|
||||
"tsup": "^8.3.5",
|
||||
"@types/react": "^19.1.4",
|
||||
"@types/react-dom": "^19.1.5"
|
||||
"@types/react-dom": "^19.1.5",
|
||||
"rimraf": "^6.0.1",
|
||||
"tsup": "^8.3.5"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
"@radix-ui/react-dialog": "^1.1.14",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.14",
|
||||
"@radix-ui/react-slot": "^1.2.2",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"clsx": "^2.1.1",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
|
@ -23,13 +27,13 @@
|
|||
"zod": "^3.24.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/eslint-config": "workspace:*",
|
||||
"@repo/typescript-config": "workspace:*",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@turbo/gen": "^2.5.3",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19.1.4",
|
||||
"@types/react-dom": "^19.1.5",
|
||||
"@repo/eslint-config": "workspace:*",
|
||||
"@repo/typescript-config": "workspace:*",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"lucide-react": "0.511.0",
|
||||
"tailwindcss": "^4",
|
||||
|
|
|
@ -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,34 +1,39 @@
|
|||
import type * as React from 'react';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
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';
|
||||
import { cn } from "@repo/ui/lib/utils"
|
||||
|
||||
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: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground shadow-sm hover:bg-primary/90',
|
||||
destructive: 'bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90',
|
||||
outline: 'border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||
destructive:
|
||||
"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",
|
||||
outline:
|
||||
"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:
|
||||
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
||||
sm: 'h-8 rounded-md px-3 has-[>svg]:px-2.5',
|
||||
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
|
||||
icon: 'size-9',
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
|
@ -36,13 +41,19 @@ function Button({
|
|||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<'button'> &
|
||||
}: React.ComponentProps<"button"> &
|
||||
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