This commit is contained in:
ditiqi 2025-05-28 08:25:57 +08:00
commit 4e9bd17fe0
53 changed files with 6718 additions and 379 deletions

1
.cursorignore Normal file
View File

@ -0,0 +1 @@
# Add directories or file patterns to ignore during indexing (e.g. foo/ or *.csv)

133
OIDC_ARCHITECTURE_UPDATE.md Normal file
View File

@ -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 中处理

View File

@ -1,11 +1,286 @@
To install dependencies: # OIDC Provider Demo
```sh
bun install 这是一个基于 Hono 后端的 OpenID Connect (OIDC) Provider 演示应用使用了标准的OIDC架构设计。
## 功能特性
- ✅ 完整的 OIDC Provider 实现
- ✅ 支持授权码流程 (Authorization Code Flow)
- ✅ 支持 PKCE (Proof Key for Code Exchange)
- ✅ 内置认证处理器
- ✅ JWT 令牌签发和验证
- ✅ 用户信息端点
- ✅ 令牌撤销
- ✅ Redis 存储适配器
- ✅ 内置登录页面
## 标准OIDC架构
### 使用内置认证处理器(推荐)
```typescript
import { createOIDCProvider } from '@repo/oidc-provider';
// 使用内置认证处理器Provider自己处理登录
const oidcApp = createOIDCProvider({
config: oidcConfig,
useBuiltInAuth: true,
builtInAuthConfig: {
passwordValidator: validatePassword,
sessionTTL: 24 * 60 * 60, // 24小时
loginPageTitle: 'OIDC Demo 登录',
brandName: 'OIDC Demo Provider',
},
});
``` ```
To run: ### 自定义认证处理器(高级用法)
```sh
```typescript
import { createOIDCProvider, type AuthHandler } from '@repo/oidc-provider';
class MyAuthHandler implements AuthHandler {
async getCurrentUser(c: Context): Promise<string | null> {
// 检查用户认证状态
// 例如从JWT token、session或cookie中获取用户ID
const token = c.req.header('Authorization');
return await verifyTokenAndGetUserId(token);
}
async handleAuthRequired(c: Context, authRequest: AuthorizationRequest): Promise<Response> {
// 处理未认证用户
// 例如:显示自定义登录页面或重定向到外部认证服务
return this.showCustomLoginPage(c, authRequest);
}
}
const oidcApp = createOIDCProvider({
config: oidcConfig,
authHandler: new MyAuthHandler()
});
```
## 启动服务
```bash
# 在项目根目录
cd apps/backend
bun run dev bun run dev
``` ```
open http://localhost:3000 服务将在 `http://localhost:8000` 启动。
## 当前配置
当前的 OIDC Provider 已配置为:
- 使用内置认证处理器Provider自己处理所有登录逻辑
- 支持标准OIDC授权码流程
- 使用 Redis 存储令牌和会话
## OIDC 端点
### 发现文档
```
GET http://localhost:8000/oidc/.well-known/openid-configuration
```
### 主要端点
- **授权端点**: `http://localhost:8000/oidc/auth`
- **令牌端点**: `http://localhost:8000/oidc/token`
- **用户信息端点**: `http://localhost:8000/oidc/userinfo`
- **JWKS端点**: `http://localhost:8000/oidc/.well-known/jwks.json`
- **撤销端点**: `http://localhost:8000/oidc/revoke`
## 测试客户端
### 机密客户端 (Confidential Client)
```
Client ID: demo-client
Client Secret: demo-client-secret
重定向URI:
- http://localhost:3000/callback
- http://localhost:8080/callback
- https://oauth.pstmn.io/v1/callback
```
### 公共客户端 (Public Client)
```
Client ID: demo-public-client
重定向URI:
- http://localhost:3000/callback
- myapp://callback
```
## 测试用户
```
用户名: demouser
密码: password
用户ID (sub): demo-user
```
## API 测试
运行测试脚本验证新API
```bash
cd apps/backend
bun run test-oidc.ts
```
## 授权流程
1. **客户端发起授权请求**: 访问 `/oidc/auth` 端点
2. **检查用户认证**: AuthHandler 检查用户是否已认证
3. **用户认证**: 如未认证,重定向到登录页面
4. **生成授权码**: 认证成功后生成授权码
5. **交换访问令牌**: 客户端使用授权码换取访问令牌
6. **访问资源**: 使用访问令牌访问用户信息等资源
## 配置说明
```typescript
import { createOIDCProvider, DefaultAuthHandler } from '@repo/oidc-provider';
const oidcConfig = {
issuer: 'http://localhost:8000/oidc',
signingKey: 'your-secret-key',
storage: redisAdapter,
findClient: async (clientId) => { /* 查找客户端 */ },
findUser: async (userId) => { /* 查找用户 */ },
// ... 其他配置
};
// 方式1: 使用内置认证处理器(推荐)
const app1 = createOIDCProvider({
config: oidcConfig,
useBuiltInAuth: true,
builtInAuthConfig: {
passwordValidator: validatePassword,
sessionTTL: 24 * 60 * 60,
loginPageTitle: 'OIDC Demo 登录',
brandName: 'OIDC Demo Provider',
},
});
// 方式2: 使用自定义处理器(高级用法)
const app2 = createOIDCProvider({
config: oidcConfig,
authHandler: new CustomAuthHandler()
});
```
## 与 Next.js Web 应用集成
1. **标准OIDC流程**: Next.js 应用使用标准 oidc-client-ts 库
2. **认证处理**: OIDC Provider 自己处理所有用户认证和登录页面
3. **令牌管理**: OIDC Provider 负责生成和管理所有令牌
4. **回调处理**: Next.js 应用只需处理OIDC回调获取令牌
## 技术架构
- **框架**: Hono.js
- **存储**: Redis (令牌存储)
- **JWT**: JOSE 库
- **PKCE**: 支持 SHA256 和 plain 方法
- **算法**: HS256 (可配置 RS256, ES256)
- **认证**: 可自定义认证处理器
## 优势
相比之前的实现新API具有以下优势
1. **简化配置**: 只需要提供配置对象和认证处理器
2. **灵活认证**: 支持任意认证方式JWT、Session、OAuth等
3. **清晰分离**: 认证逻辑与OIDC协议逻辑分离
4. **易于扩展**: 通过实现 AuthHandler 接口轻松自定义
5. **类型安全**: 完整的 TypeScript 类型支持
## 开发说明
- 修改客户端或用户数据:编辑 `src/oidc-demo.ts`
- 自定义认证逻辑:实现 `AuthHandler` 接口
- 配置调整:修改 `oidcConfig` 对象
## 快速测试
1. **Web界面测试**
访问 http://localhost:8000 查看完整的测试界面
2. **授权流程测试**
访问 http://localhost:8000/oidc/auth?response_type=code&client_id=demo-client&redirect_uri=https://oauth.pstmn.io/v1/callback&scope=openid%20profile%20email&state=test-state
3. **Postman测试**
使用以下配置在 Postman 中测试 OAuth 2.0:
```
授权URL: http://localhost:8000/oidc/auth
令牌URL: http://localhost:8000/oidc/token
回调URL: https://oauth.pstmn.io/v1/callback
Client ID: demo-client
Client Secret: demo-client-secret
作用域: openid profile email
```
## 完整的授权码流程
### 1. 授权请求
```
GET http://localhost:8000/oidc/auth?response_type=code&client_id=demo-client&redirect_uri=https://oauth.pstmn.io/v1/callback&scope=openid%20profile%20email&state=random-state
```
### 2. 用户登录
系统会自动重定向到登录页面,使用测试用户登录
### 3. 获取授权码
登录成功后会重定向到 `redirect_uri` 并携带授权码
### 4. 交换访问令牌
```bash
curl -X POST http://localhost:8000/oidc/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=authorization_code&code=YOUR_CODE&redirect_uri=https://oauth.pstmn.io/v1/callback&client_id=demo-client&client_secret=demo-client-secret"
```
### 5. 访问用户信息
```bash
curl -X GET http://localhost:8000/oidc/userinfo \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"
```
## 支持的作用域和声明
### 作用域 (Scopes)
- `openid` - 必需的基础作用域
- `profile` - 用户基本信息
- `email` - 邮箱信息
- `phone` - 电话号码
- `address` - 地址信息
### 声明 (Claims)
- `sub` - 用户唯一标识
- `name`, `given_name`, `family_name` - 姓名信息
- `email`, `email_verified` - 邮箱信息
- `phone_number`, `phone_number_verified` - 电话信息
- `picture`, `profile`, `website` - 个人资料
- `gender`, `birthdate`, `zoneinfo`, `locale` - 基本信息
- `address` - 地址信息
- `updated_at` - 更新时间
## 安全注意事项
⚠️ **这是一个演示应用,不应用于生产环境!**
生产环境部署时需要注意:
1. 使用强密钥和安全的签名算法
2. 实现真实的用户认证和会话管理
3. 添加适当的安全头和 CSRF 保护
4. 使用 HTTPS
5. 实现客户端注册和管理
6. 添加速率限制和监控
7. 定期轮换密钥
## 开发和扩展
如需修改客户端或用户数据,编辑 `src/oidc-demo.ts` 文件中的 `demoClients``demoUsers` 数组。
如需自定义认证逻辑,修改 `src/index.ts` 中的登录处理逻辑。

View File

@ -1,32 +1,32 @@
{ {
"name": "backend", "name": "backend",
"scripts": { "scripts": {
"dev": "bun run --hot src/index.ts" "dev": "bun run --hot src/index.ts"
}, },
"dependencies": { "dependencies": {
"@elastic/elasticsearch": "^9.0.2", "@elastic/elasticsearch": "^9.0.2",
"@hono/trpc-server": "^0.3.4", "@hono/node-server": "^1.14.3",
"@hono/zod-validator": "^0.5.0", "@hono/trpc-server": "^0.3.4",
"@repo/db": "workspace:*", "@hono/zod-validator": "^0.5.0",
"@repo/tus": "workspace:*", "@repo/db": "workspace:*",
"@trpc/server": "11.1.2", "@repo/oidc-provider": "workspace:*",
"@types/oidc-provider": "^9.1.0", "@trpc/server": "11.1.2",
"hono": "^4.7.10", "hono": "^4.7.10",
"ioredis": "5.4.1", "ioredis": "5.4.1",
"jose": "^6.0.11", "jose": "^6.0.11",
"minio": "7.1.3", "minio": "7.1.3",
"nanoid": "^5.1.5", "nanoid": "^5.1.5",
"nanoid-cjs": "^0.0.7", "node-cron": "^4.0.7",
"transliteration": "^2.3.5", "oidc-provider": "^9.1.1",
"node-cron": "^4.0.7", "superjson": "^2.2.2",
"oidc-provider": "^9.1.1", "valibot": "^1.1.0",
"superjson": "^2.2.2", "zod": "^3.25.23"
"dayjs": "^1.11.13", },
"dotenv": "^16.4.7", "devDependencies": {
"zod": "^3.25.23" "@types/bun": "latest",
}, "@types/node": "^22.15.21",
"devDependencies": { "@types/oidc-provider": "^9.1.0",
"@types/bun": "latest", "supertest": "^7.1.1",
"@types/node": "^22.15.21" "vitest": "^3.1.4"
} }
} }

View File

@ -9,7 +9,7 @@ import Redis from 'ioredis';
import redis from './redis'; import redis from './redis';
import minioClient from './minio'; import minioClient from './minio';
import { Client } from 'minio'; import { Client } from 'minio';
import oidc from './oidc/provider';
import { appRouter } from './trpc'; import { appRouter } from './trpc';
import { createBunWebSocket } from 'hono/bun'; import { createBunWebSocket } from 'hono/bun';
@ -60,12 +60,7 @@ app.use(
app.route('/api/users', userRest); app.route('/api/users', userRest);
app.route('/api/upload', uploadRest); app.route('/api/upload', uploadRest);
app.use('/oidc/*', async (c, next) => {
// @ts-ignore
await oidc.callback(c.req.raw, c.res.raw);
// return void 也可以
return;
});
// 添加 WebSocket 路由 // 添加 WebSocket 路由
app.get('/ws', wsHandler); app.get('/ws', wsHandler);

View File

@ -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 };

View File

@ -1,111 +0,0 @@
import { Configuration } from 'oidc-provider';
import { RedisAdapter } from './redis-adapter';
import { prisma } from '@repo/db';
async function getClients() {
const dbClients = await prisma.oidcClient.findMany?.();
const dbClientList =
dbClients && dbClients.length > 0
? dbClients.map((c) => ({
client_id: c.clientId,
client_secret: c.clientSecret,
grant_types: JSON.parse(c.grantTypes), // string -> string[]
redirect_uris: JSON.parse(c.redirectUris), // string -> string[]
response_types: JSON.parse(c.responseTypes), // string -> string[]
scope: c.scope,
}))
: [];
// 管理后台client通过环境变量读取
const defaultClient = {
client_id: process.env.OIDC_CLIENT_ID || 'admin-client',
client_secret: process.env.OIDC_CLIENT_SECRET || 'admin-secret',
grant_types: ['authorization_code', 'refresh_token'],
redirect_uris: [process.env.OIDC_REDIRECT_URI || 'http://localhost:3000/callback'],
response_types: ['code'],
scope: 'openid email profile',
};
// 检查是否与数据库client_id重复
const allClients = [defaultClient, ...dbClientList.filter((c) => c.client_id !== defaultClient.client_id)];
return allClients;
}
const OIDC_COOKIE_KEY = process.env.OIDC_COOKIE_KEY || 'HrbEPlzByV0CcjFJhl2pjKV2iG8FgQIc';
const config: Configuration = {
adapter: RedisAdapter,
// 注意clients字段现在是Promise需在Provider初始化时await
clients: await getClients(),
pkce: {
required: () => true,
},
features: {
devInteractions: { enabled: false },
resourceIndicators: { enabled: true },
revocation: { enabled: true },
userinfo: { enabled: true },
registration: { enabled: true },
},
cookies: {
keys: [OIDC_COOKIE_KEY],
},
jwks: {
keys: [
{
d: 'VEZOsY07JTFzGTqv6cC2Y32vsfChind2I_TTuvV225_-0zrSej3XLRg8iE_u0-3GSgiGi4WImmTwmEgLo4Qp3uEcxCYbt4NMJC7fwT2i3dfRZjtZ4yJwFl0SIj8TgfQ8ptwZbFZUlcHGXZIr4nL8GXyQT0CK8wy4COfmymHrrUoyfZA154ql_OsoiupSUCRcKVvZj2JHL2KILsq_sh_l7g2dqAN8D7jYfJ58MkqlknBMa2-zi5I0-1JUOwztVNml_zGrp27UbEU60RqV3GHjoqwI6m01U7K0a8Q_SQAKYGqgepbAYOA-P4_TLl5KC4-WWBZu_rVfwgSENwWNEhw8oQ',
dp: 'E1Y-SN4bQqX7kP-bNgZ_gEv-pixJ5F_EGocHKfS56jtzRqQdTurrk4jIVpI-ZITA88lWAHxjD-OaoJUh9Jupd_lwD5Si80PyVxOMI2xaGQiF0lbKJfD38Sh8frRpgelZVaK_gm834B6SLfxKdNsP04DsJqGKktODF_fZeaGFPH0',
dq: 'F90JPxevQYOlAgEH0TUt1-3_hyxY6cfPRU2HQBaahyWrtCWpaOzenKZnvGFZdg-BuLVKjCchq3G_70OLE-XDP_ol0UTJmDTT-WyuJQdEMpt_WFF9yJGoeIu8yohfeLatU-67ukjghJ0s9CBzNE_LrGEV6Cup3FXywpSYZAV3iqc',
e: 'AQAB',
kty: 'RSA',
n: 'xwQ72P9z9OYshiQ-ntDYaPnnfwG6u9JAdLMZ5o0dmjlcyrvwQRdoFIKPnO65Q8mh6F_LDSxjxa2Yzo_wdjhbPZLjfUJXgCzm54cClXzT5twzo7lzoAfaJlkTsoZc2HFWqmcri0BuzmTFLZx2Q7wYBm0pXHmQKF0V-C1O6NWfd4mfBhbM-I1tHYSpAMgarSm22WDMDx-WWI7TEzy2QhaBVaENW9BKaKkJklocAZCxk18WhR0fckIGiWiSM5FcU1PY2jfGsTmX505Ub7P5Dz75Ygqrutd5tFrcqyPAtPTFDk8X1InxkkUwpP3nFU5o50DGhwQolGYKPGtQ-ZtmbOfcWQ',
p: '5wC6nY6Ev5FqcLPCqn9fC6R9KUuBej6NaAVOKW7GXiOJAq2WrileGKfMc9kIny20zW3uWkRLm-O-3Yzze1zFpxmqvsvCxZ5ERVZ6leiNXSu3tez71ZZwp0O9gys4knjrI-9w46l_vFuRtjL6XEeFfHEZFaNJpz-lcnb3w0okrbM',
q: '3I1qeEDslZFB8iNfpKAdWtz_Wzm6-jayT_V6aIvhvMj5mnU-Xpj75zLPQSGa9wunMlOoZW9w1wDO1FVuDhwzeOJaTm-Ds0MezeC4U6nVGyyDHb4CUA3ml2tzt4yLrqGYMT7XbADSvuWYADHw79OFjEi4T3s3tJymhaBvy1ulv8M',
qi: 'wSbXte9PcPtr788e713KHQ4waE26CzoXx-JNOgN0iqJMN6C4_XJEX-cSvCZDf4rh7xpXN6SGLVd5ibIyDJi7bbi5EQ5AXjazPbLBjRthcGXsIuZ3AtQyR0CEWNSdM7EyM5TRdyZQ9kftfz9nI03guW3iKKASETqX2vh0Z8XRjyU',
use: 'sig',
},
{
crv: 'P-256',
d: 'K9xfPv773dZR22TVUB80xouzdF7qCg5cWjPjkHyv7Ws',
kty: 'EC',
use: 'sig',
x: 'FWZ9rSkLt6Dx9E3pxLybhdM6xgR5obGsj5_pqmnz5J4',
y: '_n8G69C-A2Xl4xUW2lF0i8ZGZnk_KPYrhv4GbTGu5G4',
},
],
},
ttl: {
AccessToken: 3600,
AuthorizationCode: 600,
IdToken: 3600,
RefreshToken: 1209600,
BackchannelAuthenticationRequest: 600,
ClientCredentials: 600,
DeviceCode: 600,
Grant: 1209600,
Interaction: 3600,
Session: 1209600,
RegistrationAccessToken: 3600,
DPoPProof: 300,
PushedAuthorizationRequest: 600,
ReplayDetection: 3600,
LogoutToken: 600,
},
findAccount: async (ctx, id) => {
const user = await prisma.user.findUnique({ where: { id } });
if (!user) return undefined;
return {
accountId: user.id,
async claims() {
return {
sub: user.id,
email: user.email,
name: user.name,
};
},
};
},
};
export default config;

View File

@ -1,6 +0,0 @@
import { Provider } from 'oidc-provider';
import config from './config';
const oidc = new Provider('http://localhost:3000', config);
export default oidc;

View File

@ -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}`;
}
}

View File

@ -3,6 +3,6 @@ import { publicProcedure, router } from "../trpc/base"
export const userRouter = router({ export const userRouter = router({
getUser: publicProcedure.query(async ({ ctx }) => { getUser: publicProcedure.query(async ({ ctx }) => {
return '123' return '1234'
}) })
}) })

View File

@ -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

View File

@ -1,26 +1,170 @@
'use client'; 'use client';
import { useHello, useTRPC } from '@repo/client';
import { useQuery } from '@tanstack/react-query';
import Link from 'next/link';
export default function Home() { import { useAuth } from '@/providers/auth-provider';
const trpc = useTRPC(); import { UserProfile } from '@/components/user-profile';
const { data, isLoading } = useQuery(trpc.user.getUser.queryOptions()); import { LoginButton } from '@/components/login-button';
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 ( return (
<div className="p-4"> <div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 dark:from-gray-900 dark:to-gray-800">
<h1 className="text-2xl font-bold mb-4"></h1> <div className="container mx-auto px-4 py-8">
<div className="space-y-4"> {/* 页面标题 */}
<div className="p-4 border rounded"> <div className="text-center mb-8">
<h2 className="text-xl mb-2"></h2> <h1 className="text-4xl font-bold text-gray-900 dark:text-white mb-4">OIDC </h1>
<ul className="space-y-2"> <p className="text-xl text-gray-600 dark:text-gray-300 max-w-2xl mx-auto">
<li> OpenID Connect
<Link href="/websocket" className="text-blue-500 hover:text-blue-600 hover:underline"> </p>
WebSocket <div className="mt-4">
</Link> <a
</li> 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"
</ul> >
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> </div>
</div> </div>

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -3,6 +3,7 @@
import * as React from 'react'; import * as React from 'react';
import { ThemeProvider as NextThemesProvider } from 'next-themes'; import { ThemeProvider as NextThemesProvider } from 'next-themes';
import QueryProvider from '@/providers/query-provider'; import QueryProvider from '@/providers/query-provider';
import { AuthProvider } from '@/providers/auth-provider';
export function Providers({ children }: { children: React.ReactNode }) { export function Providers({ children }: { children: React.ReactNode }) {
return ( return (
@ -13,7 +14,9 @@ export function Providers({ children }: { children: React.ReactNode }) {
disableTransitionOnChange disableTransitionOnChange
enableColorScheme enableColorScheme
> >
<QueryProvider>{children}</QueryProvider> <QueryProvider>
<AuthProvider>{children}</AuthProvider>
</QueryProvider>
</NextThemesProvider> </NextThemesProvider>
); );
} }

View File

@ -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>
);
}

View File

@ -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`,
};

View File

@ -1,40 +1,45 @@
{ {
"name": "web", "name": "web",
"version": "0.0.1", "version": "0.0.1",
"type": "module", "type": "module",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --turbopack -p 3001", "dev": "next dev --turbopack -p 3001",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint" "lint": "next lint"
}, },
"dependencies": { "dependencies": {
"@repo/client": "workspace:*", "@radix-ui/react-avatar": "^1.1.10",
"@repo/db": "workspace:*", "@radix-ui/react-label": "^2.1.7",
"@repo/ui": "workspace:*", "@radix-ui/react-separator": "^1.1.7",
"@tanstack/react-query": "^5.51.21", "@radix-ui/react-slot": "^1.2.3",
"@trpc/client": "11.1.2", "@repo/client": "workspace:*",
"@trpc/react-query": "11.1.2", "@repo/db": "workspace:*",
"@trpc/server": "11.1.2", "@repo/ui": "workspace:*",
"@trpc/tanstack-react-query": "11.1.2", "@tanstack/react-query": "^5.51.21",
"axios": "^1.7.2", "@trpc/client": "11.1.2",
"dayjs": "^1.11.12", "@trpc/react-query": "11.1.2",
"lucide-react": "0.511.0", "@trpc/server": "11.1.2",
"next": "15.3.2", "@trpc/tanstack-react-query": "11.1.2",
"next-themes": "^0.4.6", "axios": "^1.7.2",
"react": "^19.1.0", "dayjs": "^1.11.12",
"tus-js-client": "^4.1.0", "lucide-react": "0.511.0",
"react-dom": "^19.1.0", "next": "15.3.2",
"superjson": "^2.2.2" "next-themes": "^0.4.6",
}, "oidc-client-ts": "^3.2.1",
"devDependencies": { "react": "^19.1.0",
"@repo/eslint-config": "workspace:*", "react-dom": "^19.1.0",
"@repo/typescript-config": "workspace:*", "superjson": "^2.2.2",
"@tailwindcss/postcss": "^4", "valibot": "^1.1.0"
"@types/react": "^19.1.4", },
"@types/react-dom": "^19.1.5", "devDependencies": {
"tailwindcss": "^4", "@repo/eslint-config": "workspace:*",
"typescript": "^5" "@repo/typescript-config": "workspace:*",
} "@tailwindcss/postcss": "^4",
"@types/react": "^19.1.4",
"@types/react-dom": "^19.1.5",
"tailwindcss": "^4",
"typescript": "^5"
}
} }

View File

@ -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>;
};

View File

@ -3,15 +3,12 @@
"version": "1.0.0", "version": "1.0.0",
"exports": { "exports": {
".": "./src/index.ts" ".": "./src/index.ts"
}, },
"sideEffects": false, "sideEffects": false,
"files": [ "files": [
"dist", "dist",
"src" "src"
], ],
"dependencies": {
},
"peerDependencies": { "peerDependencies": {
"@tanstack/query-async-storage-persister": "^5.51.9", "@tanstack/query-async-storage-persister": "^5.51.9",
"@tanstack/react-query": "^5.51.21", "@tanstack/react-query": "^5.51.21",
@ -25,9 +22,9 @@
"react": "^19.1.0" "react": "^19.1.0"
}, },
"devDependencies": { "devDependencies": {
"rimraf": "^6.0.1",
"tsup": "^8.3.5",
"@types/react": "^19.1.4", "@types/react": "^19.1.4",
"@types/react-dom": "^19.1.5" "@types/react-dom": "^19.1.5",
"rimraf": "^6.0.1",
"tsup": "^8.3.5"
} }
} }

View File

@ -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"
}

View File

@ -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);
}
}

View File

@ -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';

View File

@ -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);
}
}
}

View File

@ -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,
};
}
}

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
}

View File

@ -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';

View File

@ -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');
}

View File

@ -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();
}
}

View File

@ -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>;
}

View File

@ -0,0 +1,2 @@
export type { StorageAdapter } from './adapter';
export { RedisStorageAdapter } from './redis-adapter';

View File

@ -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);
}
}

View File

@ -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>;

View File

@ -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;
}
}
}
}
/**
* JWKSJSON 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',
},
};
}
}

View File

@ -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 763643-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,
};
}
}

View File

@ -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);
}
}

View File

@ -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"
]
}

View File

@ -14,8 +14,12 @@
"lint": "eslint . --max-warnings 0" "lint": "eslint . --max-warnings 0"
}, },
"dependencies": { "dependencies": {
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.14", "@radix-ui/react-dropdown-menu": "^2.1.14",
"@radix-ui/react-slot": "^1.2.2", "@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
@ -23,13 +27,13 @@
"zod": "^3.24.4" "zod": "^3.24.4"
}, },
"devDependencies": { "devDependencies": {
"@repo/eslint-config": "workspace:*",
"@repo/typescript-config": "workspace:*",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@turbo/gen": "^2.5.3", "@turbo/gen": "^2.5.3",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19.1.4", "@types/react": "^19.1.4",
"@types/react-dom": "^19.1.5", "@types/react-dom": "^19.1.5",
"@repo/eslint-config": "workspace:*",
"@repo/typescript-config": "workspace:*",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"lucide-react": "0.511.0", "lucide-react": "0.511.0",
"tailwindcss": "^4", "tailwindcss": "^4",

View File

@ -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 }

View File

@ -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 }

View File

@ -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 }

View File

@ -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,
}

View File

@ -1,48 +1,59 @@
import type * as React from 'react'; import * as React from "react"
import { Slot } from '@radix-ui/react-slot'; import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from 'class-variance-authority'; import { cva, type VariantProps } from "class-variance-authority"
import { cn } from '@repo/ui/lib/utils'; import { cn } from "@repo/ui/lib/utils"
const buttonVariants = cva( const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[color,box-shadow] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 focus-visible:ring-4 focus-visible:outline-1 aria-invalid:focus-visible:ring-0", "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{ {
variants: { variants: {
variant: { variant: {
default: 'bg-primary text-primary-foreground shadow-sm hover:bg-primary/90', default:
destructive: 'bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90', "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
outline: 'border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground', destructive:
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80', "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
ghost: 'hover:bg-accent hover:text-accent-foreground', outline:
link: 'text-primary underline-offset-4 hover:underline', "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
}, secondary:
size: { "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
default: 'h-9 px-4 py-2 has-[>svg]:px-3', ghost:
sm: 'h-8 rounded-md px-3 has-[>svg]:px-2.5', "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4', link: "text-primary underline-offset-4 hover:underline",
icon: 'size-9', },
}, size: {
}, default: "h-9 px-4 py-2 has-[>svg]:px-3",
defaultVariants: { sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
variant: 'default', lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
size: 'default', icon: "size-9",
}, },
}, },
); defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({ function Button({
className, className,
variant, variant,
size, size,
asChild = false, asChild = false,
...props ...props
}: React.ComponentProps<'button'> & }: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & { VariantProps<typeof buttonVariants> & {
asChild?: boolean; asChild?: boolean
}) { }) {
const Comp = asChild ? Slot : 'button'; const Comp = asChild ? Slot : "button"
return <Comp data-slot="button" className={cn(buttonVariants({ variant, size, className }))} {...props} />; return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
} }
export { Button, buttonVariants }; export { Button, buttonVariants }

View File

@ -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,
}

View File

@ -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,
}

View File

@ -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 }

View File

@ -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 }

View File

@ -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 }

File diff suppressed because it is too large Load Diff

1
test-oidc.ts Normal file
View File

@ -0,0 +1 @@