feat: 实现完整的 OIDC Provider 功能
- 后端:基于 node-oidc-provider 实现 OIDC Provider - 支持 authorization_code、refresh_token、client_credentials 授权类型 - Redis adapter 存储会话数据,Prisma adapter 存储持久化数据 - 客户端管理 CRUD API(创建、更新、删除、重新生成密钥) - 交互 API(登录、授权确认、中止) - 第一方应用自动跳过授权确认页面 - 使用 cuid2 生成客户端 ID - 前端:OIDC 客户端管理界面 - 客户端列表表格(支持分页、排序) - 创建/编辑弹窗(支持所有 OIDC 配置字段) - OIDC 交互页面(登录表单、授权确认表单) - 共享类型:添加 OIDC 相关 TypeScript 类型定义 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -28,6 +28,8 @@ JWT_EXPIRES_IN="7d"
|
||||
PORT=4000
|
||||
# 运行环境: development | production | test
|
||||
NODE_ENV=development
|
||||
# 前端 URL(用于 OIDC 交互重定向)
|
||||
FRONTEND_URL=http://localhost:3000
|
||||
|
||||
# ----- 加密配置 -----
|
||||
# 是否启用通信加密 (true/false)
|
||||
@@ -68,3 +70,13 @@ MINIO_BUCKET=seclusion
|
||||
# 如果设置,将使用此 URL 作为文件访问地址前缀
|
||||
# 示例: https://cdn.example.com 或 https://example.com/storage
|
||||
MINIO_PUBLIC_URL=
|
||||
|
||||
# ----- OIDC Provider 配置 -----
|
||||
# OIDC 签发者 URL(必须是可公开访问的 URL,包含 /oidc 路径)
|
||||
OIDC_ISSUER=http://localhost:4000/oidc
|
||||
# OIDC Cookie 签名密钥(生产环境必须修改)
|
||||
OIDC_COOKIE_SECRET=oidc-cookie-secret-change-in-production
|
||||
# OIDC JWKS 私钥(RS256,PEM 格式,Base64 编码)
|
||||
# 生成方式: openssl genrsa 2048 | base64 -w 0
|
||||
# 注意: 生产环境必须配置,开发环境可留空使用临时密钥
|
||||
OIDC_JWKS_PRIVATE_KEY=
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"@nestjs/passport": "^10.0.3",
|
||||
"@nestjs/platform-express": "^10.4.15",
|
||||
"@nestjs/swagger": "^8.1.0",
|
||||
"@paralleldrive/cuid2": "^3.0.6",
|
||||
"@prisma/client": "^6.1.0",
|
||||
"@seclusion/shared": "workspace:*",
|
||||
"bcrypt": "^5.1.1",
|
||||
@@ -36,6 +37,7 @@
|
||||
"minio": "^8.0.6",
|
||||
"nanoid": "^5.1.6",
|
||||
"nodemailer": "^7.0.12",
|
||||
"oidc-provider": "^9.6.0",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
@@ -55,6 +57,7 @@
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/nodemailer": "^7.0.5",
|
||||
"@types/oidc-provider": "^9.5.0",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/uuid": "^11.0.0",
|
||||
"dotenv-cli": "^11.0.0",
|
||||
|
||||
@@ -20,6 +20,7 @@ model User {
|
||||
|
||||
roles UserRole[]
|
||||
uploadFiles File[] @relation("FileUploader")
|
||||
oidcGrants OidcGrant[]
|
||||
|
||||
// 复合唯一约束:未删除用户邮箱唯一,已删除用户邮箱可重复
|
||||
@@unique([email, deletedAt])
|
||||
@@ -229,3 +230,73 @@ model ClassTeacher {
|
||||
@@unique([classId, teacherId])
|
||||
@@map("class_teachers")
|
||||
}
|
||||
|
||||
// ============ OIDC Provider 模块 ============
|
||||
|
||||
// OIDC 客户端应用
|
||||
model OidcClient {
|
||||
id String @id @default(cuid(2))
|
||||
clientId String @unique // 客户端 ID
|
||||
clientSecret String? // 客户端密钥(公开客户端可为空)
|
||||
clientName String // 客户端名称
|
||||
clientUri String? // 客户端主页
|
||||
logoUri String? // Logo URL
|
||||
redirectUris String[] // 回调地址列表
|
||||
postLogoutRedirectUris String[] // 登出后回调地址
|
||||
grantTypes String[] // 授权类型: authorization_code, refresh_token, client_credentials
|
||||
responseTypes String[] // 响应类型: code, token, id_token
|
||||
scopes String[] // 允许的 scope: openid, profile, email, etc.
|
||||
tokenEndpointAuthMethod String @default("client_secret_basic") // 认证方式
|
||||
applicationType String @default("web") // web / native
|
||||
isEnabled Boolean @default(true)
|
||||
isFirstParty Boolean @default(false) // 是否为第一方应用(跳过授权确认)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deletedAt DateTime?
|
||||
|
||||
grants OidcGrant[]
|
||||
refreshTokens OidcRefreshToken[]
|
||||
|
||||
@@map("oidc_clients")
|
||||
}
|
||||
|
||||
// OIDC 刷新令牌(长期,需要持久化)
|
||||
model OidcRefreshToken {
|
||||
id String @id @default(cuid(2))
|
||||
jti String @unique // JWT ID
|
||||
grantId String // 关联的授权 ID
|
||||
clientId String // 客户端 ID
|
||||
userId String // 用户 ID
|
||||
scope String // 授权范围
|
||||
data Json // 完整令牌数据
|
||||
expiresAt DateTime
|
||||
consumedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
client OidcClient @relation(fields: [clientId], references: [clientId])
|
||||
|
||||
@@index([grantId])
|
||||
@@index([clientId])
|
||||
@@index([userId])
|
||||
@@map("oidc_refresh_tokens")
|
||||
}
|
||||
|
||||
// OIDC 授权记录(用户对客户端的授权)
|
||||
model OidcGrant {
|
||||
id String @id @default(cuid(2))
|
||||
grantId String @unique // oidc-provider 生成的 grant ID
|
||||
clientId String // 客户端 ID
|
||||
userId String // 用户 ID
|
||||
scope String // 已授权的 scope
|
||||
data Json // 完整授权数据
|
||||
expiresAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
client OidcClient @relation(fields: [clientId], references: [clientId])
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
|
||||
@@unique([clientId, userId])
|
||||
@@index([userId])
|
||||
@@map("oidc_grants")
|
||||
}
|
||||
|
||||
@@ -43,6 +43,11 @@ const permissions = [
|
||||
{ code: 'student:read', name: '查看学生', resource: 'student', action: 'read' },
|
||||
{ code: 'student:update', name: '更新学生', resource: 'student', action: 'update' },
|
||||
{ code: 'student:delete', name: '删除学生', resource: 'student', action: 'delete' },
|
||||
// OIDC 客户端管理权限
|
||||
{ code: 'oidc-client:create', name: '创建 OIDC 客户端', resource: 'oidc-client', action: 'create' },
|
||||
{ code: 'oidc-client:read', name: '查看 OIDC 客户端', resource: 'oidc-client', action: 'read' },
|
||||
{ code: 'oidc-client:update', name: '更新 OIDC 客户端', resource: 'oidc-client', action: 'update' },
|
||||
{ code: 'oidc-client:delete', name: '删除 OIDC 客户端', resource: 'oidc-client', action: 'delete' },
|
||||
];
|
||||
|
||||
// 初始角色数据
|
||||
@@ -171,6 +176,15 @@ const menus = [
|
||||
sort: 4,
|
||||
isStatic: true,
|
||||
},
|
||||
{
|
||||
code: 'oidc-client-management',
|
||||
name: 'OIDC 客户端',
|
||||
type: 'menu',
|
||||
path: '/oidc-clients',
|
||||
icon: 'KeyRound',
|
||||
sort: 5,
|
||||
isStatic: true,
|
||||
},
|
||||
{
|
||||
code: 'profile',
|
||||
name: '个人中心',
|
||||
@@ -198,6 +212,7 @@ const systemSubMenuCodes = [
|
||||
'role-management',
|
||||
'permission-management',
|
||||
'menu-management',
|
||||
'oidc-client-management',
|
||||
];
|
||||
|
||||
// 教学管理子菜单 codes
|
||||
|
||||
@@ -12,6 +12,7 @@ import { MailModule } from './common/mail/mail.module';
|
||||
import { RedisModule } from './common/redis/redis.module';
|
||||
import { StorageModule } from './common/storage/storage.module';
|
||||
import { FileModule } from './file/file.module';
|
||||
import { OidcModule } from './oidc/oidc.module';
|
||||
import { PermissionModule } from './permission/permission.module';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { StudentModule } from './student/student.module';
|
||||
@@ -36,6 +37,7 @@ import { UserModule } from './user/user.module';
|
||||
AuthModule,
|
||||
UserModule,
|
||||
PermissionModule,
|
||||
OidcModule,
|
||||
// 教学管理模块
|
||||
TeacherModule,
|
||||
ClassModule,
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { Logger, ValidationPipe } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||
|
||||
import { AppModule } from './app.module';
|
||||
import { EncryptionInterceptor } from './common/crypto/encryption.interceptor';
|
||||
import { OidcService } from './oidc/oidc.service';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { version } = require('../package.json');
|
||||
|
||||
const logger = new Logger('Bootstrap');
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule, {
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
|
||||
// 日志级别: 'log' | 'error' | 'warn' | 'debug' | 'verbose'
|
||||
// 开发环境显示所有日志,生产环境只显示 error/warn/log
|
||||
logger:
|
||||
@@ -23,6 +27,19 @@ async function bootstrap() {
|
||||
const port = configService.get<number>('PORT', 4000);
|
||||
const enableEncryption = configService.get<string>('ENABLE_ENCRYPTION') === 'true';
|
||||
|
||||
// 挂载 OIDC Provider(必须在 NestJS 路由注册之前)
|
||||
const oidcService = app.get(OidcService);
|
||||
const provider = oidcService.getProvider();
|
||||
if (provider) {
|
||||
// 获取底层 Express 实例
|
||||
const expressApp = app.getHttpAdapter().getInstance();
|
||||
// 使用 Express 原生方式挂载 oidc-provider 到 /oidc 路径
|
||||
expressApp.use('/oidc', provider.callback());
|
||||
logger.log('OIDC Provider middleware mounted');
|
||||
} else {
|
||||
logger.warn('OIDC Provider not initialized');
|
||||
}
|
||||
|
||||
// 全局验证管道
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
@@ -39,8 +56,9 @@ async function bootstrap() {
|
||||
}
|
||||
|
||||
// CORS 配置
|
||||
const frontendUrl = configService.get<string>('FRONTEND_URL', 'http://localhost:3000');
|
||||
app.enableCors({
|
||||
origin: ['http://localhost:3000'],
|
||||
origin: [frontendUrl],
|
||||
credentials: true,
|
||||
exposedHeaders: ['X-Encrypted'],
|
||||
});
|
||||
@@ -56,9 +74,10 @@ async function bootstrap() {
|
||||
SwaggerModule.setup('api/docs', app, document);
|
||||
|
||||
await app.listen(port);
|
||||
console.log(`🚀 Application is running on: http://localhost:${port}`);
|
||||
console.log(`📚 Swagger docs: http://localhost:${port}/api/docs`);
|
||||
console.log(`🔐 Encryption: ${enableEncryption ? 'enabled' : 'disabled'}`);
|
||||
logger.log(`Application is running on: http://localhost:${port}`);
|
||||
logger.log(`Swagger docs: http://localhost:${port}/api/docs`);
|
||||
logger.log(`Encryption: ${enableEncryption ? 'enabled' : 'disabled'}`);
|
||||
logger.log(`OIDC Discovery: http://localhost:${port}/oidc/.well-known/openid-configuration`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
|
||||
41
apps/api/src/oidc/adapters/index.ts
Normal file
41
apps/api/src/oidc/adapters/index.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { Adapter } from 'oidc-provider';
|
||||
|
||||
import { PrismaAdapter } from './prisma.adapter';
|
||||
import { RedisAdapter } from './redis.adapter';
|
||||
|
||||
import { RedisService } from '@/common/redis/redis.service';
|
||||
import { PrismaService } from '@/prisma/prisma.service';
|
||||
|
||||
|
||||
// Prisma 存储的模型
|
||||
const PRISMA_MODELS = ['Client', 'Grant', 'RefreshToken'];
|
||||
|
||||
// Redis 存储的模型
|
||||
const REDIS_MODELS = [
|
||||
'AuthorizationCode',
|
||||
'AccessToken',
|
||||
'Session',
|
||||
'Interaction',
|
||||
'DeviceCode',
|
||||
'BackchannelAuthenticationRequest',
|
||||
'RegistrationAccessToken',
|
||||
'ReplayDetection',
|
||||
'PushedAuthorizationRequest',
|
||||
];
|
||||
|
||||
/**
|
||||
* 创建混合适配器工厂
|
||||
* 根据模型类型选择 Prisma 或 Redis 适配器
|
||||
*/
|
||||
export function createAdapterFactory(prisma: PrismaService, redis: RedisService) {
|
||||
return (model: string): Adapter => {
|
||||
if (PRISMA_MODELS.includes(model)) {
|
||||
return new PrismaAdapter(prisma, model);
|
||||
}
|
||||
if (REDIS_MODELS.includes(model)) {
|
||||
return new RedisAdapter(redis, model);
|
||||
}
|
||||
// 默认使用 Redis 适配器
|
||||
return new RedisAdapter(redis, model);
|
||||
};
|
||||
}
|
||||
168
apps/api/src/oidc/adapters/prisma.adapter.ts
Normal file
168
apps/api/src/oidc/adapters/prisma.adapter.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import type { Adapter, AdapterPayload, ResponseType } from 'oidc-provider';
|
||||
|
||||
import { PrismaService } from '@/prisma/prisma.service';
|
||||
|
||||
/**
|
||||
* Prisma 适配器
|
||||
* 用于存储持久化数据:Client、Grant、RefreshToken
|
||||
*/
|
||||
export class PrismaAdapter implements Adapter {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private model: string
|
||||
) {}
|
||||
|
||||
async upsert(id: string, payload: AdapterPayload, expiresIn: number): Promise<void> {
|
||||
const expiresAt = expiresIn ? new Date(Date.now() + expiresIn * 1000) : null;
|
||||
|
||||
switch (this.model) {
|
||||
case 'Client':
|
||||
// Client 由管理接口创建,这里不处理
|
||||
break;
|
||||
|
||||
case 'Grant': {
|
||||
// oidc-provider 的 Grant payload 中,scope 存储在 openid.scope 属性中
|
||||
const openid = payload.openid as { scope?: string } | undefined;
|
||||
const scopeString = openid?.scope || '';
|
||||
|
||||
await this.prisma.oidcGrant.upsert({
|
||||
where: { grantId: id },
|
||||
create: {
|
||||
grantId: id,
|
||||
clientId: payload.clientId as string,
|
||||
userId: payload.accountId as string,
|
||||
scope: scopeString,
|
||||
data: payload as object,
|
||||
expiresAt,
|
||||
},
|
||||
update: {
|
||||
scope: scopeString,
|
||||
data: payload as object,
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'RefreshToken':
|
||||
await this.prisma.oidcRefreshToken.upsert({
|
||||
where: { jti: id },
|
||||
create: {
|
||||
jti: id,
|
||||
grantId: payload.grantId as string,
|
||||
clientId: payload.clientId as string,
|
||||
userId: payload.accountId as string,
|
||||
scope: (payload.scope as string) || '',
|
||||
data: payload as object,
|
||||
expiresAt: expiresAt!,
|
||||
},
|
||||
update: {
|
||||
data: payload as object,
|
||||
expiresAt: expiresAt!,
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async find(id: string): Promise<AdapterPayload | undefined> {
|
||||
switch (this.model) {
|
||||
case 'Client': {
|
||||
const client = await this.prisma.oidcClient.findUnique({
|
||||
where: { clientId: id, isEnabled: true },
|
||||
});
|
||||
if (!client) return undefined;
|
||||
|
||||
// 转换为 oidc-provider 期望的格式
|
||||
return {
|
||||
client_id: client.clientId,
|
||||
client_secret: client.clientSecret ?? undefined,
|
||||
client_name: client.clientName,
|
||||
client_uri: client.clientUri ?? undefined,
|
||||
logo_uri: client.logoUri ?? undefined,
|
||||
redirect_uris: client.redirectUris,
|
||||
post_logout_redirect_uris: client.postLogoutRedirectUris,
|
||||
grant_types: client.grantTypes,
|
||||
response_types: client.responseTypes as ResponseType[],
|
||||
scope: client.scopes.join(' '),
|
||||
token_endpoint_auth_method: client.tokenEndpointAuthMethod as 'client_secret_basic' | 'client_secret_post' | 'none',
|
||||
application_type: client.applicationType as 'web' | 'native',
|
||||
// 自定义属性
|
||||
'urn:custom:first_party': client.isFirstParty,
|
||||
};
|
||||
}
|
||||
|
||||
case 'Grant': {
|
||||
const grant = await this.prisma.oidcGrant.findUnique({
|
||||
where: { grantId: id },
|
||||
});
|
||||
if (!grant) return undefined;
|
||||
if (grant.expiresAt && grant.expiresAt < new Date()) return undefined;
|
||||
return grant.data as AdapterPayload;
|
||||
}
|
||||
|
||||
case 'RefreshToken': {
|
||||
const token = await this.prisma.oidcRefreshToken.findUnique({
|
||||
where: { jti: id },
|
||||
});
|
||||
if (!token) return undefined;
|
||||
if (token.expiresAt < new Date()) return undefined;
|
||||
return token.data as AdapterPayload;
|
||||
}
|
||||
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async findByUserCode(_userCode: string): Promise<AdapterPayload | undefined> {
|
||||
// Prisma 适配器不支持 userCode 查找
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async findByUid(_uid: string): Promise<AdapterPayload | undefined> {
|
||||
// Prisma 适配器不支持 uid 查找
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async consume(id: string): Promise<void> {
|
||||
switch (this.model) {
|
||||
case 'RefreshToken':
|
||||
await this.prisma.oidcRefreshToken.update({
|
||||
where: { jti: id },
|
||||
data: { consumedAt: new Date() },
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async destroy(id: string): Promise<void> {
|
||||
switch (this.model) {
|
||||
case 'Grant':
|
||||
await this.prisma.oidcGrant.delete({
|
||||
where: { grantId: id },
|
||||
}).catch(() => {
|
||||
// 忽略不存在的记录
|
||||
});
|
||||
break;
|
||||
|
||||
case 'RefreshToken':
|
||||
await this.prisma.oidcRefreshToken.delete({
|
||||
where: { jti: id },
|
||||
}).catch(() => {
|
||||
// 忽略不存在的记录
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async revokeByGrantId(grantId: string): Promise<void> {
|
||||
switch (this.model) {
|
||||
case 'RefreshToken':
|
||||
await this.prisma.oidcRefreshToken.deleteMany({
|
||||
where: { grantId },
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
128
apps/api/src/oidc/adapters/redis.adapter.ts
Normal file
128
apps/api/src/oidc/adapters/redis.adapter.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import type { Adapter, AdapterPayload } from 'oidc-provider';
|
||||
|
||||
import { RedisService } from '@/common/redis/redis.service';
|
||||
|
||||
// Redis Key 前缀
|
||||
const KEY_PREFIX = 'oidc';
|
||||
|
||||
// 各模型的默认 TTL(秒)
|
||||
const MODEL_TTL: Record<string, number> = {
|
||||
AuthorizationCode: 600, // 10 分钟
|
||||
AccessToken: 3600, // 1 小时
|
||||
Session: 14 * 24 * 3600, // 14 天
|
||||
Interaction: 3600, // 1 小时
|
||||
DeviceCode: 600, // 10 分钟
|
||||
BackchannelAuthenticationRequest: 600, // 10 分钟
|
||||
};
|
||||
|
||||
/**
|
||||
* Redis 适配器
|
||||
* 用于存储短期/临时数据:AuthorizationCode、AccessToken、Session、Interaction
|
||||
*/
|
||||
export class RedisAdapter implements Adapter {
|
||||
private keyPrefix: string;
|
||||
|
||||
constructor(
|
||||
private redis: RedisService,
|
||||
private model: string
|
||||
) {
|
||||
this.keyPrefix = `${KEY_PREFIX}:${model.toLowerCase()}`;
|
||||
}
|
||||
|
||||
private key(id: string): string {
|
||||
return `${this.keyPrefix}:${id}`;
|
||||
}
|
||||
|
||||
private grantKey(grantId: string): string {
|
||||
return `${KEY_PREFIX}:grant:${grantId}`;
|
||||
}
|
||||
|
||||
private userCodeKey(userCode: string): string {
|
||||
return `${KEY_PREFIX}:usercode:${userCode}`;
|
||||
}
|
||||
|
||||
private uidKey(uid: string): string {
|
||||
return `${KEY_PREFIX}:uid:${uid}`;
|
||||
}
|
||||
|
||||
async upsert(id: string, payload: AdapterPayload, expiresIn: number): Promise<void> {
|
||||
const key = this.key(id);
|
||||
const ttl = expiresIn || MODEL_TTL[this.model] || 3600;
|
||||
|
||||
// 存储主数据
|
||||
await this.redis.setJson(key, payload, ttl);
|
||||
|
||||
// 如果有 grantId,建立索引
|
||||
if (payload.grantId) {
|
||||
const grantKey = this.grantKey(payload.grantId);
|
||||
await this.redis.sadd(grantKey, key);
|
||||
await this.redis.expire(grantKey, ttl);
|
||||
}
|
||||
|
||||
// 如果有 userCode,建立索引
|
||||
if (payload.userCode) {
|
||||
const userCodeKey = this.userCodeKey(payload.userCode);
|
||||
await this.redis.set(userCodeKey, id, ttl);
|
||||
}
|
||||
|
||||
// 如果有 uid,建立索引
|
||||
if (payload.uid) {
|
||||
const uidKey = this.uidKey(payload.uid);
|
||||
await this.redis.set(uidKey, id, ttl);
|
||||
}
|
||||
}
|
||||
|
||||
async find(id: string): Promise<AdapterPayload | undefined> {
|
||||
const data = await this.redis.getJson<AdapterPayload>(this.key(id));
|
||||
return data ?? undefined;
|
||||
}
|
||||
|
||||
async findByUserCode(userCode: string): Promise<AdapterPayload | undefined> {
|
||||
const id = await this.redis.get(this.userCodeKey(userCode));
|
||||
if (!id) return undefined;
|
||||
return this.find(id);
|
||||
}
|
||||
|
||||
async findByUid(uid: string): Promise<AdapterPayload | undefined> {
|
||||
const id = await this.redis.get(this.uidKey(uid));
|
||||
if (!id) return undefined;
|
||||
return this.find(id);
|
||||
}
|
||||
|
||||
async consume(id: string): Promise<void> {
|
||||
const data = await this.find(id);
|
||||
if (data) {
|
||||
data.consumed = Math.floor(Date.now() / 1000);
|
||||
const ttl = await this.redis.ttl(this.key(id));
|
||||
if (ttl > 0) {
|
||||
await this.redis.setJson(this.key(id), data, ttl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async destroy(id: string): Promise<void> {
|
||||
const data = await this.find(id);
|
||||
if (data) {
|
||||
// 清理关联索引
|
||||
if (data.grantId) {
|
||||
const grantKey = this.grantKey(data.grantId);
|
||||
await this.redis.getClient().srem(grantKey, this.key(id));
|
||||
}
|
||||
if (data.userCode) {
|
||||
await this.redis.del(this.userCodeKey(data.userCode));
|
||||
}
|
||||
if (data.uid) {
|
||||
await this.redis.del(this.uidKey(data.uid));
|
||||
}
|
||||
}
|
||||
await this.redis.del(this.key(id));
|
||||
}
|
||||
|
||||
async revokeByGrantId(grantId: string): Promise<void> {
|
||||
const grantKey = this.grantKey(grantId);
|
||||
const keys = await this.redis.smembers(grantKey);
|
||||
if (keys.length > 0) {
|
||||
await this.redis.del(...keys, grantKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
97
apps/api/src/oidc/client.controller.ts
Normal file
97
apps/api/src/oidc/client.controller.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiCreatedResponse,
|
||||
ApiNoContentResponse,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
CreateOidcClientDto,
|
||||
CreateOidcClientResponseDto,
|
||||
OidcClientResponseDto,
|
||||
RegenerateSecretResponseDto,
|
||||
UpdateOidcClientDto,
|
||||
} from './dto/client.dto';
|
||||
import { OidcClientService } from './services/client.service';
|
||||
|
||||
import { JwtAuthGuard } from '@/auth/guards/jwt-auth.guard';
|
||||
import { createPaginatedResponseDto } from '@/common/crud/dto/paginated-response.dto';
|
||||
import { PaginationQueryDto } from '@/common/crud/dto/pagination.dto';
|
||||
import { RequirePermission } from '@/permission/decorators/require-permission.decorator';
|
||||
import { PermissionGuard } from '@/permission/guards/permission.guard';
|
||||
|
||||
|
||||
// 分页响应 DTO
|
||||
class PaginatedOidcClientResponseDto extends createPaginatedResponseDto(OidcClientResponseDto) {}
|
||||
|
||||
@ApiTags('OIDC 客户端管理')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||
@Controller('oidc-clients')
|
||||
export class OidcClientController {
|
||||
constructor(private readonly clientService: OidcClientService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermission('oidc-client:read')
|
||||
@ApiOperation({ summary: '获取客户端列表' })
|
||||
@ApiOkResponse({ type: PaginatedOidcClientResponseDto, description: '客户端列表' })
|
||||
findAll(@Query() query: PaginationQueryDto) {
|
||||
return this.clientService.findAll(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermission('oidc-client:read')
|
||||
@ApiOperation({ summary: '获取客户端详情' })
|
||||
@ApiOkResponse({ type: OidcClientResponseDto, description: '客户端详情' })
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.clientService.findById(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePermission('oidc-client:create')
|
||||
@ApiOperation({ summary: '创建客户端' })
|
||||
@ApiCreatedResponse({ type: CreateOidcClientResponseDto, description: '创建成功,返回客户端信息和密钥' })
|
||||
create(@Body() dto: CreateOidcClientDto) {
|
||||
return this.clientService.createClient(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RequirePermission('oidc-client:update')
|
||||
@ApiOperation({ summary: '更新客户端' })
|
||||
@ApiOkResponse({ type: OidcClientResponseDto, description: '更新成功' })
|
||||
update(@Param('id') id: string, @Body() dto: UpdateOidcClientDto) {
|
||||
return this.clientService.updateClient(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RequirePermission('oidc-client:delete')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: '删除客户端' })
|
||||
@ApiNoContentResponse({ description: '删除成功' })
|
||||
remove(@Param('id') id: string) {
|
||||
return this.clientService.delete(id);
|
||||
}
|
||||
|
||||
@Post(':id/regenerate-secret')
|
||||
@RequirePermission('oidc-client:update')
|
||||
@ApiOperation({ summary: '重新生成客户端密钥' })
|
||||
@ApiOkResponse({ type: RegenerateSecretResponseDto, description: '新的客户端密钥' })
|
||||
regenerateSecret(@Param('id') id: string) {
|
||||
return this.clientService.regenerateSecret(id);
|
||||
}
|
||||
}
|
||||
124
apps/api/src/oidc/config/oidc.config.ts
Normal file
124
apps/api/src/oidc/config/oidc.config.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import type { Adapter, Configuration, KoaContextWithOIDC } from 'oidc-provider';
|
||||
|
||||
import type { OidcAccountService } from '../services/account.service';
|
||||
|
||||
/**
|
||||
* 创建 OIDC Provider 配置
|
||||
*/
|
||||
export function createOidcConfiguration(
|
||||
_issuer: string,
|
||||
accountService: OidcAccountService,
|
||||
adapterFactory: (model: string) => Adapter,
|
||||
cookieKeys: string[],
|
||||
jwks: { keys: object[] },
|
||||
frontendUrl: string
|
||||
): Configuration {
|
||||
const isDev = process.env.NODE_ENV !== 'production';
|
||||
|
||||
return {
|
||||
// 适配器工厂
|
||||
adapter: adapterFactory,
|
||||
|
||||
// 账户查找
|
||||
findAccount: accountService.findAccount.bind(accountService),
|
||||
|
||||
// JWKS 配置
|
||||
jwks,
|
||||
|
||||
// 支持的功能
|
||||
features: {
|
||||
devInteractions: { enabled: false }, // 禁用开发模式交互
|
||||
rpInitiatedLogout: { enabled: true },
|
||||
revocation: { enabled: true },
|
||||
introspection: { enabled: true },
|
||||
},
|
||||
|
||||
// 支持的 claims
|
||||
claims: {
|
||||
openid: ['sub'],
|
||||
profile: ['name', 'picture'],
|
||||
email: ['email', 'email_verified'],
|
||||
},
|
||||
|
||||
// 令牌有效期
|
||||
ttl: {
|
||||
AccessToken: 3600, // 1 小时
|
||||
AuthorizationCode: 600, // 10 分钟
|
||||
RefreshToken: 30 * 24 * 3600, // 30 天
|
||||
IdToken: 3600, // 1 小时
|
||||
Interaction: 3600, // 1 小时
|
||||
Session: 14 * 24 * 3600, // 14 天
|
||||
Grant: 14 * 24 * 3600, // 14 天
|
||||
},
|
||||
|
||||
// Cookie 配置
|
||||
cookies: {
|
||||
keys: cookieKeys,
|
||||
long: {
|
||||
signed: true,
|
||||
path: '/', // 确保所有路径都能访问 Cookie
|
||||
sameSite: 'lax', // 允许同站点请求携带 Cookie
|
||||
secure: !isDev, // 开发环境允许 HTTP
|
||||
},
|
||||
short: {
|
||||
signed: true,
|
||||
path: '/',
|
||||
sameSite: 'lax',
|
||||
secure: !isDev,
|
||||
},
|
||||
},
|
||||
|
||||
// 交互 URL(重定向到前端页面)
|
||||
interactions: {
|
||||
url: (_ctx: KoaContextWithOIDC, interaction) =>
|
||||
`${frontendUrl}/oidc/interaction/${interaction.uid}`,
|
||||
},
|
||||
|
||||
// 路由前缀(相对于挂载点)
|
||||
routes: {
|
||||
authorization: '/authorize',
|
||||
token: '/token',
|
||||
userinfo: '/userinfo',
|
||||
jwks: '/jwks',
|
||||
revocation: '/revoke',
|
||||
introspection: '/introspect',
|
||||
end_session: '/logout',
|
||||
},
|
||||
|
||||
// PKCE 配置
|
||||
pkce: {
|
||||
required: () => false, // 可选,公开客户端建议启用
|
||||
},
|
||||
|
||||
// 响应类型
|
||||
responseTypes: ['code', 'code id_token', 'id_token', 'none'],
|
||||
|
||||
// 客户端默认配置
|
||||
clientDefaults: {
|
||||
grant_types: ['authorization_code', 'refresh_token'],
|
||||
response_types: ['code'],
|
||||
token_endpoint_auth_method: 'client_secret_basic',
|
||||
},
|
||||
|
||||
// 额外的客户端元数据
|
||||
extraClientMetadata: {
|
||||
properties: ['urn:custom:first_party'],
|
||||
},
|
||||
|
||||
// 渲染错误
|
||||
renderError: async (ctx, out, _error) => {
|
||||
ctx.type = 'html';
|
||||
ctx.body = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>OIDC Error</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>OIDC Error</h1>
|
||||
<pre>${JSON.stringify(out, null, 2)}</pre>
|
||||
</body>
|
||||
</html>`;
|
||||
},
|
||||
};
|
||||
}
|
||||
186
apps/api/src/oidc/dto/client.dto.ts
Normal file
186
apps/api/src/oidc/dto/client.dto.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger';
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUrl,
|
||||
} from 'class-validator';
|
||||
|
||||
/**
|
||||
* 创建 OIDC 客户端 DTO
|
||||
*/
|
||||
export class CreateOidcClientDto {
|
||||
@ApiProperty({ example: 'My Application', description: '客户端名称' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '客户端名称不能为空' })
|
||||
clientName: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'https://example.com', description: '客户端主页' })
|
||||
@IsOptional()
|
||||
@IsUrl({}, { message: '客户端主页必须是有效的 URL' })
|
||||
clientUri?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'https://example.com/logo.png', description: 'Logo URL' })
|
||||
@IsOptional()
|
||||
@IsUrl({}, { message: 'Logo URL 必须是有效的 URL' })
|
||||
logoUri?: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: ['https://example.com/callback'],
|
||||
description: '回调地址列表',
|
||||
})
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
redirectUris: string[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: ['https://example.com/logout'],
|
||||
description: '登出后回调地址',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
postLogoutRedirectUris?: string[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: ['authorization_code', 'refresh_token'],
|
||||
description: '授权类型',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsIn(['authorization_code', 'refresh_token', 'client_credentials'], { each: true })
|
||||
grantTypes?: string[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: ['code'],
|
||||
description: '响应类型',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsIn(['code', 'token', 'id_token', 'none'], { each: true })
|
||||
responseTypes?: string[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: ['openid', 'profile', 'email'],
|
||||
description: '允许的 scope',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
scopes?: string[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: 'client_secret_basic',
|
||||
description: '令牌端点认证方式',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn(['client_secret_basic', 'client_secret_post', 'none'])
|
||||
tokenEndpointAuthMethod?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: 'web',
|
||||
description: '应用类型',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn(['web', 'native'])
|
||||
applicationType?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: false,
|
||||
description: '是否为第一方应用(跳过授权确认)',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isFirstParty?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 OIDC 客户端 DTO
|
||||
*/
|
||||
export class UpdateOidcClientDto extends PartialType(CreateOidcClientDto) {
|
||||
@ApiPropertyOptional({ example: true, description: '是否启用' })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isEnabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* OIDC 客户端响应 DTO
|
||||
*/
|
||||
export class OidcClientResponseDto {
|
||||
@ApiProperty({ example: 'clxxx', description: '记录 ID' })
|
||||
id: string;
|
||||
|
||||
@ApiProperty({ example: 'my-app-client-id', description: '客户端 ID' })
|
||||
clientId: string;
|
||||
|
||||
@ApiProperty({ example: 'My Application', description: '客户端名称' })
|
||||
clientName: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'https://example.com', description: '客户端主页' })
|
||||
clientUri: string | null;
|
||||
|
||||
@ApiPropertyOptional({ example: 'https://example.com/logo.png', description: 'Logo URL' })
|
||||
logoUri: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
example: ['https://example.com/callback'],
|
||||
description: '回调地址列表',
|
||||
})
|
||||
redirectUris: string[];
|
||||
|
||||
@ApiProperty({
|
||||
example: ['https://example.com/logout'],
|
||||
description: '登出后回调地址',
|
||||
})
|
||||
postLogoutRedirectUris: string[];
|
||||
|
||||
@ApiProperty({
|
||||
example: ['authorization_code', 'refresh_token'],
|
||||
description: '授权类型',
|
||||
})
|
||||
grantTypes: string[];
|
||||
|
||||
@ApiProperty({ example: ['code'], description: '响应类型' })
|
||||
responseTypes: string[];
|
||||
|
||||
@ApiProperty({ example: ['openid', 'profile', 'email'], description: '允许的 scope' })
|
||||
scopes: string[];
|
||||
|
||||
@ApiProperty({ example: 'client_secret_basic', description: '令牌端点认证方式' })
|
||||
tokenEndpointAuthMethod: string;
|
||||
|
||||
@ApiProperty({ example: 'web', description: '应用类型' })
|
||||
applicationType: string;
|
||||
|
||||
@ApiProperty({ example: true, description: '是否启用' })
|
||||
isEnabled: boolean;
|
||||
|
||||
@ApiProperty({ example: false, description: '是否为第一方应用' })
|
||||
isFirstParty: boolean;
|
||||
|
||||
@ApiProperty({ example: '2026-01-20T10:00:00.000Z', description: '创建时间' })
|
||||
createdAt: string;
|
||||
|
||||
@ApiProperty({ example: '2026-01-20T10:00:00.000Z', description: '更新时间' })
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建客户端响应 DTO(包含密钥)
|
||||
*/
|
||||
export class CreateOidcClientResponseDto extends OidcClientResponseDto {
|
||||
@ApiProperty({ example: 'secret-xxx', description: '客户端密钥(仅创建时返回)' })
|
||||
clientSecret: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新生成密钥响应 DTO
|
||||
*/
|
||||
export class RegenerateSecretResponseDto {
|
||||
@ApiProperty({ example: 'new-secret-xxx', description: '新的客户端密钥' })
|
||||
clientSecret: string;
|
||||
}
|
||||
99
apps/api/src/oidc/dto/interaction.dto.ts
Normal file
99
apps/api/src/oidc/dto/interaction.dto.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsArray, IsEmail, IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
/**
|
||||
* 登录请求 DTO
|
||||
*/
|
||||
export class OidcLoginDto {
|
||||
@ApiProperty({ example: 'user@example.com', description: '邮箱' })
|
||||
@IsEmail({}, { message: '邮箱格式不正确' })
|
||||
@IsNotEmpty({ message: '邮箱不能为空' })
|
||||
email: string;
|
||||
|
||||
@ApiProperty({ example: 'password123', description: '密码' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '密码不能为空' })
|
||||
password: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 授权确认请求 DTO
|
||||
*/
|
||||
export class OidcConsentDto {
|
||||
@ApiProperty({
|
||||
example: ['openid', 'profile', 'email'],
|
||||
description: '授权的 scope 列表',
|
||||
})
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
scopes: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 交互详情响应 DTO
|
||||
*/
|
||||
export class OidcInteractionDetailsDto {
|
||||
@ApiProperty({ example: 'abc123', description: '交互 UID' })
|
||||
uid: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: { name: 'login', details: {} },
|
||||
description: '提示类型',
|
||||
})
|
||||
prompt: {
|
||||
name: 'login' | 'consent';
|
||||
details?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
@ApiProperty({
|
||||
example: {
|
||||
client_id: 'my-app',
|
||||
redirect_uri: 'https://example.com/callback',
|
||||
scope: 'openid profile email',
|
||||
},
|
||||
description: '请求参数',
|
||||
})
|
||||
params: {
|
||||
client_id: string;
|
||||
redirect_uri: string;
|
||||
scope: string;
|
||||
response_type?: string;
|
||||
state?: string;
|
||||
nonce?: string;
|
||||
};
|
||||
|
||||
@ApiProperty({
|
||||
example: {
|
||||
clientId: 'my-app',
|
||||
clientName: 'My Application',
|
||||
logoUri: 'https://example.com/logo.png',
|
||||
},
|
||||
description: '客户端信息',
|
||||
})
|
||||
client: {
|
||||
clientId: string;
|
||||
clientName: string;
|
||||
logoUri?: string;
|
||||
clientUri?: string;
|
||||
};
|
||||
|
||||
@ApiProperty({
|
||||
example: { accountId: 'user123' },
|
||||
description: '会话信息(已登录时存在)',
|
||||
required: false,
|
||||
})
|
||||
session?: {
|
||||
accountId: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 交互结果响应 DTO
|
||||
*/
|
||||
export class OidcInteractionResultDto {
|
||||
@ApiProperty({
|
||||
example: 'https://example.com/callback?code=xxx',
|
||||
description: '重定向 URL',
|
||||
})
|
||||
redirectTo: string;
|
||||
}
|
||||
202
apps/api/src/oidc/oidc.controller.ts
Normal file
202
apps/api/src/oidc/oidc.controller.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Logger,
|
||||
Param,
|
||||
Post,
|
||||
Req,
|
||||
Res,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
|
||||
import { OidcConsentDto, OidcLoginDto } from './dto/interaction.dto';
|
||||
import { OidcService } from './oidc.service';
|
||||
import { OidcInteractionService } from './services/interaction.service';
|
||||
|
||||
import { Public } from '@/auth/decorators/public.decorator';
|
||||
|
||||
@ApiTags('OIDC 交互')
|
||||
@Controller('oidc-interaction')
|
||||
export class OidcController {
|
||||
private readonly logger = new Logger(OidcController.name);
|
||||
|
||||
constructor(
|
||||
private readonly oidcService: OidcService,
|
||||
private readonly interactionService: OidcInteractionService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 获取交互详情
|
||||
*/
|
||||
@Get(':uid')
|
||||
@Public()
|
||||
@ApiOperation({ summary: '获取 OIDC 交互详情' })
|
||||
async getInteraction(@Req() req: Request, @Res() res: Response, @Param('uid') uid: string) {
|
||||
try {
|
||||
const details = await this.oidcService.interactionDetails(req, res);
|
||||
|
||||
// 获取客户端信息
|
||||
const provider = this.oidcService.getProvider();
|
||||
const client = await provider.Client.find(details.params.client_id as string);
|
||||
|
||||
// 检查是否是第一方应用 + 已登录用户 + consent prompt,自动完成授权
|
||||
if (
|
||||
details.prompt.name === 'consent' &&
|
||||
details.session?.accountId &&
|
||||
client?.['urn:custom:first_party']
|
||||
) {
|
||||
this.logger.debug('第一方应用自动授权', {
|
||||
clientId: details.params.client_id,
|
||||
accountId: details.session.accountId,
|
||||
});
|
||||
|
||||
const scopes = (details.params.scope as string).split(' ');
|
||||
const redirectTo = await this.interactionService.finishConsent(
|
||||
provider,
|
||||
// @ts-expect-error - KoaContextWithOIDC 类型兼容
|
||||
{ req, res },
|
||||
details.grantId as string | undefined,
|
||||
scopes,
|
||||
details.params.client_id as string,
|
||||
details.session.accountId
|
||||
);
|
||||
|
||||
return res.json({ autoConsent: true, redirectTo });
|
||||
}
|
||||
|
||||
const response = {
|
||||
uid: details.uid,
|
||||
prompt: details.prompt,
|
||||
params: {
|
||||
client_id: details.params.client_id,
|
||||
redirect_uri: details.params.redirect_uri,
|
||||
scope: details.params.scope,
|
||||
response_type: details.params.response_type,
|
||||
state: details.params.state,
|
||||
nonce: details.params.nonce,
|
||||
},
|
||||
client: client
|
||||
? {
|
||||
clientId: client.clientId,
|
||||
clientName: client.clientName || client.clientId,
|
||||
logoUri: client.logoUri,
|
||||
clientUri: client.clientUri,
|
||||
}
|
||||
: null,
|
||||
session: details.session?.accountId
|
||||
? { accountId: details.session.accountId }
|
||||
: undefined,
|
||||
};
|
||||
|
||||
return res.json(response);
|
||||
} catch (error) {
|
||||
this.logger.error(`获取交互详情失败: ${uid}`, error);
|
||||
return res.status(400).json({ error: 'invalid_request', message: '交互会话无效或已过期' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交登录
|
||||
*/
|
||||
@Post(':uid/login')
|
||||
@Public()
|
||||
@ApiOperation({ summary: '提交 OIDC 登录' })
|
||||
async submitLogin(
|
||||
@Req() req: Request,
|
||||
@Res() res: Response,
|
||||
@Param('uid') _uid: string,
|
||||
@Body() dto: OidcLoginDto
|
||||
) {
|
||||
try {
|
||||
// 验证用户
|
||||
const accountId = await this.interactionService.validateLogin(dto.email, dto.password);
|
||||
|
||||
// 获取交互详情,用于第一方应用自动授权
|
||||
const details = await this.oidcService.interactionDetails(req, res);
|
||||
const clientId = details.params.client_id as string;
|
||||
const scopes = (details.params.scope as string).split(' ');
|
||||
|
||||
// 完成登录交互(第一方应用会自动完成授权)
|
||||
const provider = this.oidcService.getProvider();
|
||||
const redirectTo = await this.interactionService.finishLogin(
|
||||
provider,
|
||||
// @ts-expect-error - KoaContextWithOIDC 类型兼容
|
||||
{ req, res },
|
||||
accountId,
|
||||
{ clientId, scopes }
|
||||
);
|
||||
|
||||
return res.json({ redirectTo });
|
||||
} catch (error) {
|
||||
this.logger.error('OIDC 登录失败', error);
|
||||
return res.status(401).json({
|
||||
error: 'authentication_failed',
|
||||
message: error instanceof Error ? error.message : '登录失败',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交授权确认
|
||||
*/
|
||||
@Post(':uid/confirm')
|
||||
@Public()
|
||||
@ApiOperation({ summary: '提交 OIDC 授权确认' })
|
||||
async submitConsent(
|
||||
@Req() req: Request,
|
||||
@Res() res: Response,
|
||||
@Param('uid') _uid: string,
|
||||
@Body() dto: OidcConsentDto
|
||||
) {
|
||||
try {
|
||||
const details = await this.oidcService.interactionDetails(req, res);
|
||||
const provider = this.oidcService.getProvider();
|
||||
|
||||
const redirectTo = await this.interactionService.finishConsent(
|
||||
provider,
|
||||
// @ts-expect-error - KoaContextWithOIDC 类型兼容
|
||||
{ req, res },
|
||||
details.grantId as string | undefined,
|
||||
dto.scopes,
|
||||
details.params.client_id as string,
|
||||
details.session!.accountId
|
||||
);
|
||||
|
||||
return res.json({ redirectTo });
|
||||
} catch (error) {
|
||||
this.logger.error('OIDC 授权确认失败', error);
|
||||
return res.status(400).json({
|
||||
error: 'consent_failed',
|
||||
message: error instanceof Error ? error.message : '授权确认失败',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 中止授权
|
||||
*/
|
||||
@Post(':uid/abort')
|
||||
@Public()
|
||||
@ApiOperation({ summary: '中止 OIDC 授权' })
|
||||
async abortInteraction(@Req() req: Request, @Res() res: Response, @Param('uid') _uid: string) {
|
||||
try {
|
||||
const provider = this.oidcService.getProvider();
|
||||
const redirectTo = await this.interactionService.abortInteraction(
|
||||
provider,
|
||||
// @ts-expect-error - KoaContextWithOIDC 类型兼容
|
||||
{ req, res }
|
||||
);
|
||||
|
||||
return res.json({ redirectTo });
|
||||
} catch (error) {
|
||||
this.logger.error('OIDC 中止授权失败', error);
|
||||
return res.status(400).json({
|
||||
error: 'abort_failed',
|
||||
message: error instanceof Error ? error.message : '中止授权失败',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
15
apps/api/src/oidc/oidc.module.ts
Normal file
15
apps/api/src/oidc/oidc.module.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { OidcClientController } from './client.controller';
|
||||
import { OidcController } from './oidc.controller';
|
||||
import { OidcService } from './oidc.service';
|
||||
import { OidcAccountService } from './services/account.service';
|
||||
import { OidcClientService } from './services/client.service';
|
||||
import { OidcInteractionService } from './services/interaction.service';
|
||||
|
||||
@Module({
|
||||
controllers: [OidcController, OidcClientController],
|
||||
providers: [OidcService, OidcAccountService, OidcInteractionService, OidcClientService],
|
||||
exports: [OidcService],
|
||||
})
|
||||
export class OidcModule {}
|
||||
152
apps/api/src/oidc/oidc.service.ts
Normal file
152
apps/api/src/oidc/oidc.service.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { createPrivateKey, generateKeyPairSync, type JsonWebKey } from 'crypto';
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import type { Request, Response } from 'express';
|
||||
import type { InteractionResults } from 'oidc-provider';
|
||||
import Provider from 'oidc-provider';
|
||||
|
||||
|
||||
import { createAdapterFactory } from './adapters';
|
||||
import { createOidcConfiguration } from './config/oidc.config';
|
||||
import { OidcAccountService } from './services/account.service';
|
||||
|
||||
import { RedisService } from '@/common/redis/redis.service';
|
||||
import { PrismaService } from '@/prisma/prisma.service';
|
||||
|
||||
/**
|
||||
* OIDC Provider 核心服务
|
||||
*/
|
||||
@Injectable()
|
||||
export class OidcService {
|
||||
private readonly logger = new Logger(OidcService.name);
|
||||
private provider: Provider;
|
||||
|
||||
constructor(
|
||||
private configService: ConfigService,
|
||||
private prisma: PrismaService,
|
||||
private redis: RedisService,
|
||||
private accountService: OidcAccountService
|
||||
) {
|
||||
// 在构造函数中初始化 provider,确保在模块注册时就可用
|
||||
this.initializeProvider();
|
||||
}
|
||||
|
||||
private initializeProvider() {
|
||||
const issuer = this.configService.get<string>('OIDC_ISSUER', 'http://localhost:4000/oidc');
|
||||
const cookieSecret = this.configService.get<string>(
|
||||
'OIDC_COOKIE_SECRET',
|
||||
'oidc-cookie-secret-change-in-production'
|
||||
);
|
||||
|
||||
// 解析 JWKS 私钥
|
||||
const jwksPrivateKeyBase64 = this.configService.get<string>('OIDC_JWKS_PRIVATE_KEY');
|
||||
let jwks: { keys: JsonWebKey[] };
|
||||
|
||||
if (jwksPrivateKeyBase64) {
|
||||
// 从 Base64 解码 PEM 格式私钥
|
||||
const privateKeyPem = Buffer.from(jwksPrivateKeyBase64, 'base64').toString('utf-8');
|
||||
const privateKey = createPrivateKey(privateKeyPem);
|
||||
const jwk = privateKey.export({ format: 'jwk' }) as JsonWebKey;
|
||||
jwks = {
|
||||
keys: [
|
||||
{
|
||||
...jwk,
|
||||
use: 'sig',
|
||||
alg: 'RS256',
|
||||
kid: 'main',
|
||||
},
|
||||
],
|
||||
};
|
||||
this.logger.log('使用配置的 JWKS 私钥');
|
||||
} else {
|
||||
// 开发环境生成临时 RSA 密钥对
|
||||
this.logger.warn('OIDC_JWKS_PRIVATE_KEY 未配置,生成临时密钥(仅限开发环境)');
|
||||
const { privateKey } = generateKeyPairSync('rsa', {
|
||||
modulusLength: 2048,
|
||||
});
|
||||
const jwk = privateKey.export({ format: 'jwk' }) as JsonWebKey;
|
||||
jwks = {
|
||||
keys: [
|
||||
{
|
||||
...jwk,
|
||||
use: 'sig',
|
||||
alg: 'RS256',
|
||||
kid: 'dev-key',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// 创建适配器工厂
|
||||
const adapterFactory = createAdapterFactory(this.prisma, this.redis);
|
||||
|
||||
// 获取前端 URL
|
||||
const frontendUrl = this.configService.get<string>('FRONTEND_URL', 'http://localhost:3000');
|
||||
|
||||
// 创建配置
|
||||
const configuration = createOidcConfiguration(
|
||||
issuer,
|
||||
this.accountService,
|
||||
adapterFactory,
|
||||
[cookieSecret],
|
||||
jwks,
|
||||
frontendUrl
|
||||
);
|
||||
|
||||
// 创建 Provider 实例
|
||||
this.provider = new Provider(issuer, configuration);
|
||||
|
||||
// 允许 HTTP(开发环境)
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
this.provider.proxy = true;
|
||||
}
|
||||
|
||||
this.logger.log(`OIDC Provider 初始化完成,Issuer: ${issuer}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Provider 实例
|
||||
*/
|
||||
getProvider(): Provider {
|
||||
return this.provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 OIDC 请求的回调函数
|
||||
*/
|
||||
callback(req: Request, res: Response): void {
|
||||
this.provider.callback()(req, res);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取交互详情
|
||||
*/
|
||||
async interactionDetails(req: Request, res: Response) {
|
||||
return this.provider.interactionDetails(req, res);
|
||||
}
|
||||
|
||||
/**
|
||||
* 完成交互
|
||||
*/
|
||||
async interactionFinished(
|
||||
req: Request,
|
||||
res: Response,
|
||||
result: InteractionResults,
|
||||
options?: { mergeWithLastSubmission?: boolean }
|
||||
) {
|
||||
return this.provider.interactionFinished(req, res, result, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取交互结果 URL
|
||||
*/
|
||||
async interactionResult(
|
||||
req: Request,
|
||||
res: Response,
|
||||
result: InteractionResults,
|
||||
options?: { mergeWithLastSubmission?: boolean }
|
||||
) {
|
||||
return this.provider.interactionResult(req, res, result, options);
|
||||
}
|
||||
}
|
||||
44
apps/api/src/oidc/services/account.service.ts
Normal file
44
apps/api/src/oidc/services/account.service.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { Account, AccountClaims, KoaContextWithOIDC } from 'oidc-provider';
|
||||
|
||||
import { PrismaService } from '@/prisma/prisma.service';
|
||||
|
||||
/**
|
||||
* OIDC 账户服务
|
||||
* 提供 oidc-provider 所需的账户查找功能
|
||||
*/
|
||||
@Injectable()
|
||||
export class OidcAccountService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
/**
|
||||
* 查找账户
|
||||
* oidc-provider 要求的 findAccount 方法
|
||||
*/
|
||||
async findAccount(_ctx: KoaContextWithOIDC, id: string): Promise<Account | undefined> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, email: true, name: true, avatarId: true },
|
||||
});
|
||||
|
||||
if (!user) return undefined;
|
||||
|
||||
return {
|
||||
accountId: user.id,
|
||||
claims: async (_use: string, scope: string): Promise<AccountClaims> => {
|
||||
const claims: AccountClaims = { sub: user.id };
|
||||
|
||||
if (scope.includes('profile')) {
|
||||
claims.name = user.name ?? undefined;
|
||||
claims.picture = user.avatarId ?? undefined;
|
||||
}
|
||||
if (scope.includes('email')) {
|
||||
claims.email = user.email;
|
||||
claims.email_verified = true;
|
||||
}
|
||||
|
||||
return claims;
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
136
apps/api/src/oidc/services/client.service.ts
Normal file
136
apps/api/src/oidc/services/client.service.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { randomBytes } from 'crypto';
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { createId } from '@paralleldrive/cuid2';
|
||||
import type { OidcClient, Prisma } from '@prisma/client';
|
||||
|
||||
import { CreateOidcClientDto, UpdateOidcClientDto } from '../dto/client.dto';
|
||||
|
||||
import { CrudOptions } from '@/common/crud/crud.decorator';
|
||||
import { CrudService } from '@/common/crud/crud.service';
|
||||
import { PrismaService } from '@/prisma/prisma.service';
|
||||
|
||||
|
||||
/**
|
||||
* OIDC 客户端管理服务
|
||||
*/
|
||||
@Injectable()
|
||||
@CrudOptions({
|
||||
defaultSelect: {
|
||||
id: true,
|
||||
clientId: true,
|
||||
clientName: true,
|
||||
clientUri: true,
|
||||
logoUri: true,
|
||||
redirectUris: true,
|
||||
postLogoutRedirectUris: true,
|
||||
grantTypes: true,
|
||||
responseTypes: true,
|
||||
scopes: true,
|
||||
tokenEndpointAuthMethod: true,
|
||||
applicationType: true,
|
||||
isEnabled: true,
|
||||
isFirstParty: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
filterableFields: [
|
||||
{ field: 'clientId', operator: 'contains' },
|
||||
{ field: 'clientName', operator: 'contains' },
|
||||
],
|
||||
})
|
||||
export class OidcClientService extends CrudService<
|
||||
OidcClient,
|
||||
Prisma.OidcClientCreateInput,
|
||||
Prisma.OidcClientUpdateInput,
|
||||
Prisma.OidcClientWhereInput,
|
||||
Prisma.OidcClientWhereUniqueInput
|
||||
> {
|
||||
constructor(prisma: PrismaService) {
|
||||
super(prisma, 'oidcClient');
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成客户端 ID(使用 cuid2)
|
||||
*/
|
||||
private generateClientId(): string {
|
||||
return createId();
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成客户端密钥(64 字符随机十六进制)
|
||||
*/
|
||||
private generateClientSecret(): string {
|
||||
return randomBytes(32).toString('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建客户端(重写以生成 clientId 和 clientSecret)
|
||||
*/
|
||||
async createClient(dto: CreateOidcClientDto) {
|
||||
const clientId = this.generateClientId();
|
||||
const clientSecret = this.generateClientSecret();
|
||||
|
||||
const data: Prisma.OidcClientCreateInput = {
|
||||
clientId,
|
||||
clientSecret,
|
||||
clientName: dto.clientName,
|
||||
clientUri: dto.clientUri,
|
||||
logoUri: dto.logoUri,
|
||||
redirectUris: dto.redirectUris,
|
||||
postLogoutRedirectUris: dto.postLogoutRedirectUris || [],
|
||||
grantTypes: dto.grantTypes || ['authorization_code', 'refresh_token'],
|
||||
responseTypes: dto.responseTypes || ['code'],
|
||||
scopes: dto.scopes || ['openid', 'profile', 'email'],
|
||||
tokenEndpointAuthMethod: dto.tokenEndpointAuthMethod || 'client_secret_basic',
|
||||
applicationType: dto.applicationType || 'web',
|
||||
isFirstParty: dto.isFirstParty || false,
|
||||
};
|
||||
|
||||
const client = await this.create(data);
|
||||
|
||||
return {
|
||||
...client,
|
||||
clientSecret, // 创建时返回密钥
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新客户端
|
||||
*/
|
||||
async updateClient(id: string, dto: UpdateOidcClientDto) {
|
||||
const data: Prisma.OidcClientUpdateInput = {};
|
||||
|
||||
if (dto.clientName !== undefined) data.clientName = dto.clientName;
|
||||
if (dto.clientUri !== undefined) data.clientUri = dto.clientUri;
|
||||
if (dto.logoUri !== undefined) data.logoUri = dto.logoUri;
|
||||
if (dto.redirectUris !== undefined) data.redirectUris = dto.redirectUris;
|
||||
if (dto.postLogoutRedirectUris !== undefined) data.postLogoutRedirectUris = dto.postLogoutRedirectUris;
|
||||
if (dto.grantTypes !== undefined) data.grantTypes = dto.grantTypes;
|
||||
if (dto.responseTypes !== undefined) data.responseTypes = dto.responseTypes;
|
||||
if (dto.scopes !== undefined) data.scopes = dto.scopes;
|
||||
if (dto.tokenEndpointAuthMethod !== undefined) data.tokenEndpointAuthMethod = dto.tokenEndpointAuthMethod;
|
||||
if (dto.applicationType !== undefined) data.applicationType = dto.applicationType;
|
||||
if (dto.isEnabled !== undefined) data.isEnabled = dto.isEnabled;
|
||||
if (dto.isFirstParty !== undefined) data.isFirstParty = dto.isFirstParty;
|
||||
|
||||
return this.update(id, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新生成客户端密钥
|
||||
*/
|
||||
async regenerateSecret(id: string) {
|
||||
// 先检查记录是否存在
|
||||
await this.findById(id);
|
||||
|
||||
const clientSecret = this.generateClientSecret();
|
||||
|
||||
await this.prisma.oidcClient.update({
|
||||
where: { id },
|
||||
data: { clientSecret },
|
||||
});
|
||||
|
||||
return { clientSecret };
|
||||
}
|
||||
}
|
||||
159
apps/api/src/oidc/services/interaction.service.ts
Normal file
159
apps/api/src/oidc/services/interaction.service.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import type { InteractionResults, KoaContextWithOIDC, Provider } from 'oidc-provider';
|
||||
|
||||
import { PrismaService } from '@/prisma/prisma.service';
|
||||
|
||||
/**
|
||||
* OIDC 交互服务
|
||||
* 处理登录、授权确认等交互流程
|
||||
*/
|
||||
@Injectable()
|
||||
export class OidcInteractionService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
/**
|
||||
* 验证用户登录
|
||||
*/
|
||||
async validateLogin(email: string, password: string): Promise<string> {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: { email },
|
||||
select: { id: true, password: true },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('用户名或密码错误');
|
||||
}
|
||||
|
||||
const isPasswordValid = await bcrypt.compare(password, user.password);
|
||||
if (!isPasswordValid) {
|
||||
throw new UnauthorizedException('用户名或密码错误');
|
||||
}
|
||||
|
||||
return user.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 完成登录交互
|
||||
* @param autoConsent 如果为 true 且是第一方应用,自动完成授权
|
||||
*/
|
||||
async finishLogin(
|
||||
provider: Provider,
|
||||
ctx: KoaContextWithOIDC,
|
||||
accountId: string,
|
||||
autoConsent?: { clientId: string; scopes: string[] }
|
||||
): Promise<string> {
|
||||
// 检查是否需要自动完成授权(第一方应用)
|
||||
if (autoConsent) {
|
||||
const isFirstParty = await this.isFirstPartyClient(autoConsent.clientId);
|
||||
if (isFirstParty) {
|
||||
// 第一方应用:同时完成登录和授权
|
||||
const grant = new provider.Grant({
|
||||
accountId,
|
||||
clientId: autoConsent.clientId,
|
||||
});
|
||||
|
||||
for (const scope of autoConsent.scopes) {
|
||||
grant.addOIDCScope(scope);
|
||||
}
|
||||
|
||||
const grantId = await grant.save();
|
||||
|
||||
const result: InteractionResults = {
|
||||
login: { accountId, remember: true },
|
||||
consent: { grantId },
|
||||
};
|
||||
|
||||
return provider.interactionResult(ctx.req, ctx.res, result, {
|
||||
mergeWithLastSubmission: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 非第一方应用:仅完成登录,后续需要授权确认
|
||||
const result: InteractionResults = {
|
||||
login: {
|
||||
accountId,
|
||||
remember: true,
|
||||
},
|
||||
};
|
||||
|
||||
return provider.interactionResult(ctx.req, ctx.res, result, {
|
||||
mergeWithLastSubmission: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 完成授权确认交互
|
||||
*/
|
||||
async finishConsent(
|
||||
provider: Provider,
|
||||
ctx: KoaContextWithOIDC,
|
||||
grantId: string | undefined,
|
||||
scopes: string[],
|
||||
clientId: string,
|
||||
accountId: string
|
||||
): Promise<string> {
|
||||
let grant;
|
||||
|
||||
if (grantId) {
|
||||
// 更新现有授权
|
||||
grant = await provider.Grant.find(grantId);
|
||||
}
|
||||
|
||||
if (!grant) {
|
||||
// 创建新授权
|
||||
grant = new provider.Grant({
|
||||
accountId,
|
||||
clientId,
|
||||
});
|
||||
}
|
||||
|
||||
// 添加授权的 scope
|
||||
for (const scope of scopes) {
|
||||
grant.addOIDCScope(scope);
|
||||
}
|
||||
|
||||
const newGrantId = await grant.save();
|
||||
|
||||
const result: InteractionResults = {
|
||||
consent: {
|
||||
grantId: newGrantId,
|
||||
},
|
||||
};
|
||||
|
||||
return provider.interactionResult(ctx.req, ctx.res, result, {
|
||||
mergeWithLastSubmission: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 中止交互
|
||||
*/
|
||||
async abortInteraction(
|
||||
provider: Provider,
|
||||
ctx: KoaContextWithOIDC,
|
||||
error: string = 'access_denied',
|
||||
errorDescription: string = '用户拒绝授权'
|
||||
): Promise<string> {
|
||||
const result: InteractionResults = {
|
||||
error,
|
||||
error_description: errorDescription,
|
||||
};
|
||||
|
||||
return provider.interactionResult(ctx.req, ctx.res, result, {
|
||||
mergeWithLastSubmission: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否为第一方应用(跳过授权确认)
|
||||
*/
|
||||
async isFirstPartyClient(clientId: string): Promise<boolean> {
|
||||
const client = await this.prisma.oidcClient.findUnique({
|
||||
where: { clientId },
|
||||
select: { isFirstParty: true },
|
||||
});
|
||||
return client?.isFirstParty ?? false;
|
||||
}
|
||||
}
|
||||
37
apps/web/src/app/(dashboard)/oidc-clients/page.tsx
Normal file
37
apps/web/src/app/(dashboard)/oidc-clients/page.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
'use client';
|
||||
|
||||
import { OidcClientsTable } from '@/components/oidc-clients';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
|
||||
export default function OidcClientsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">OIDC 客户端管理</h1>
|
||||
<p className="text-muted-foreground">
|
||||
管理 OIDC 客户端应用,配置授权回调地址和权限。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>客户端列表</CardTitle>
|
||||
<CardDescription>
|
||||
查看和管理所有 OIDC 客户端应用。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<OidcClientsTable />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
41
apps/web/src/app/(oidc)/layout.tsx
Normal file
41
apps/web/src/app/(oidc)/layout.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { ThemeToggle } from '@/components/layout/ThemeToggle';
|
||||
import { siteConfig } from '@/config/site';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: 'OIDC 授权',
|
||||
template: `%s | ${siteConfig.name}`,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* OIDC 专用布局
|
||||
* 独立于主站布局,用于 OIDC 交互页面(登录、授权确认)
|
||||
*/
|
||||
export default function OidcLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-muted/30">
|
||||
{/* 顶部导航 */}
|
||||
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="container flex h-14 items-center justify-between">
|
||||
<span className="font-bold text-xl">{siteConfig.name}</span>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 主内容区 */}
|
||||
<main className="flex-1 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">{children}</div>
|
||||
</main>
|
||||
|
||||
{/* 页脚 */}
|
||||
<footer className="border-t py-4 bg-background">
|
||||
<div className="container text-center text-sm text-muted-foreground">
|
||||
© {new Date().getFullYear()} {siteConfig.name}. All rights reserved.
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
57
apps/web/src/app/(oidc)/oidc/error/page.tsx
Normal file
57
apps/web/src/app/(oidc)/oidc/error/page.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'OIDC 错误',
|
||||
};
|
||||
|
||||
const ERROR_MESSAGES: Record<string, string> = {
|
||||
access_denied: '用户拒绝了授权请求',
|
||||
invalid_request: '无效的授权请求',
|
||||
unauthorized_client: '客户端未被授权',
|
||||
unsupported_response_type: '不支持的响应类型',
|
||||
invalid_scope: '无效的授权范围',
|
||||
server_error: '服务器内部错误',
|
||||
temporarily_unavailable: '服务暂时不可用',
|
||||
unknown_prompt: '未知的交互类型',
|
||||
interaction_not_found: '交互会话不存在或已过期',
|
||||
};
|
||||
|
||||
interface Props {
|
||||
searchParams: Promise<{ error?: string; error_description?: string }>;
|
||||
}
|
||||
|
||||
export default async function OidcErrorPage({ searchParams }: Props) {
|
||||
const { error, error_description } = await searchParams;
|
||||
const errorCode = error || 'server_error';
|
||||
const errorMessage =
|
||||
error_description || ERROR_MESSAGES[errorCode] || '发生未知错误';
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-destructive">授权失败</CardTitle>
|
||||
<CardDescription>无法完成授权流程</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="text-center">
|
||||
<p className="text-muted-foreground mb-2">{errorMessage}</p>
|
||||
<p className="text-xs text-muted-foreground">错误代码: {errorCode}</p>
|
||||
</CardContent>
|
||||
<CardFooter className="justify-center">
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/">返回首页</Link>
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
'use client';
|
||||
|
||||
import { OidcScopeDescriptions } from '@seclusion/shared';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { oidcInteractionService } from '@/services/oidc-interaction.service';
|
||||
|
||||
|
||||
interface OidcClient {
|
||||
clientId: string;
|
||||
clientName: string;
|
||||
logoUri?: string;
|
||||
clientUri?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
uid: string;
|
||||
client: OidcClient;
|
||||
requestedScopes: string[];
|
||||
}
|
||||
|
||||
export function OidcConsentForm({ uid, client, requestedScopes }: Props) {
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// 默认全部选中
|
||||
const [selectedScopes, setSelectedScopes] = useState<Set<string>>(
|
||||
new Set(requestedScopes)
|
||||
);
|
||||
|
||||
const handleScopeChange = (scope: string, checked: boolean) => {
|
||||
setSelectedScopes((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (checked) {
|
||||
next.add(scope);
|
||||
} else {
|
||||
next.delete(scope);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
setIsPending(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await oidcInteractionService.submitConsent(uid, {
|
||||
scopes: Array.from(selectedScopes),
|
||||
});
|
||||
// 直接跳转,浏览器会自动携带 Cookie
|
||||
window.location.href = result.redirectTo;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '授权失败');
|
||||
setIsPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeny = async () => {
|
||||
setIsPending(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await oidcInteractionService.abort(uid);
|
||||
window.location.href = result.redirectTo;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '操作失败');
|
||||
setIsPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-center gap-3 mb-2">
|
||||
{client.logoUri && (
|
||||
<img
|
||||
src={client.logoUri}
|
||||
alt={client.clientName}
|
||||
className="w-12 h-12 rounded"
|
||||
/>
|
||||
)}
|
||||
<div className="text-center">
|
||||
<CardTitle className="text-xl">{client.clientName}</CardTitle>
|
||||
<CardDescription>请求访问您的账户</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<CardContent>
|
||||
<p className="mb-4 text-sm text-muted-foreground">
|
||||
该应用请求以下权限:
|
||||
</p>
|
||||
{error && (
|
||||
<p className="mb-4 text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
<ul className="space-y-3">
|
||||
{requestedScopes.map((scope) => (
|
||||
<li key={scope} className="flex items-center gap-3">
|
||||
<Checkbox
|
||||
id={`scope_${scope}`}
|
||||
checked={selectedScopes.has(scope)}
|
||||
onCheckedChange={(checked) =>
|
||||
handleScopeChange(scope, checked === true)
|
||||
}
|
||||
disabled={scope === 'openid' || isPending}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`scope_${scope}`}
|
||||
className="text-sm font-normal cursor-pointer"
|
||||
>
|
||||
{OidcScopeDescriptions[scope] || scope}
|
||||
</Label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
<CardFooter className="flex gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={handleDeny}
|
||||
disabled={isPending}
|
||||
>
|
||||
拒绝
|
||||
</Button>
|
||||
<Button type="submit" className="flex-1" disabled={isPending}>
|
||||
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
授权
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
111
apps/web/src/app/(oidc)/oidc/interaction/[uid]/OidcLoginForm.tsx
Normal file
111
apps/web/src/app/(oidc)/oidc/interaction/[uid]/OidcLoginForm.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
'use client';
|
||||
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { oidcInteractionService } from '@/services/oidc-interaction.service';
|
||||
|
||||
|
||||
interface OidcClient {
|
||||
clientId: string;
|
||||
clientName: string;
|
||||
logoUri?: string;
|
||||
clientUri?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
uid: string;
|
||||
client: OidcClient;
|
||||
}
|
||||
|
||||
export function OidcLoginForm({ uid, client }: Props) {
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (formData: FormData) => {
|
||||
const email = formData.get('email') as string;
|
||||
const password = formData.get('password') as string;
|
||||
|
||||
setIsPending(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await oidcInteractionService.submitLogin(uid, { email, password });
|
||||
// 直接跳转,浏览器会自动携带 Cookie
|
||||
window.location.href = result.redirectTo;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '登录失败');
|
||||
setIsPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="space-y-1">
|
||||
<div className="flex items-center justify-center gap-3 mb-2">
|
||||
{client.logoUri && (
|
||||
<img
|
||||
src={client.logoUri}
|
||||
alt={client.clientName}
|
||||
className="w-10 h-10 rounded"
|
||||
/>
|
||||
)}
|
||||
<CardTitle className="text-xl">{client.clientName}</CardTitle>
|
||||
</div>
|
||||
<CardDescription className="text-center">
|
||||
登录以继续访问 {client.clientName}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<form action={handleSubmit}>
|
||||
<CardContent className="space-y-4">
|
||||
{error && (
|
||||
<div className="p-3 text-sm text-destructive bg-destructive/10 rounded-md">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">邮箱</Label>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder="请输入邮箱"
|
||||
autoComplete="email"
|
||||
required
|
||||
disabled={isPending}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">密码</Label>
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
disabled={isPending}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button type="submit" className="w-full" disabled={isPending}>
|
||||
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
登录
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
76
apps/web/src/app/(oidc)/oidc/interaction/[uid]/page.tsx
Normal file
76
apps/web/src/app/(oidc)/oidc/interaction/[uid]/page.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import type { OidcInteractionDetails } from '@seclusion/shared';
|
||||
import type { Metadata } from 'next';
|
||||
import { cookies } from 'next/headers';
|
||||
import { notFound, redirect } from 'next/navigation';
|
||||
|
||||
import { OidcConsentForm } from './OidcConsentForm';
|
||||
import { OidcLoginForm } from './OidcLoginForm';
|
||||
|
||||
const API_URL = process.env.API_URL || 'http://localhost:4000';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'OIDC 授权',
|
||||
};
|
||||
|
||||
interface InteractionResponse extends OidcInteractionDetails {
|
||||
autoConsent?: boolean;
|
||||
redirectTo?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 服务端获取交互详情
|
||||
*/
|
||||
async function getInteractionDetails(
|
||||
uid: string
|
||||
): Promise<InteractionResponse | null> {
|
||||
const cookieStore = await cookies();
|
||||
const res = await fetch(`${API_URL}/oidc-interaction/${uid}`, {
|
||||
headers: { Cookie: cookieStore.toString() },
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!res.ok) return null;
|
||||
return res.json();
|
||||
}
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ uid: string }>;
|
||||
}
|
||||
|
||||
export default async function OidcInteractionPage({ params }: Props) {
|
||||
const { uid } = await params;
|
||||
const details = await getInteractionDetails(uid);
|
||||
|
||||
if (!details) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// 第一方应用自动授权,直接重定向
|
||||
if (details.autoConsent && details.redirectTo) {
|
||||
redirect(details.redirectTo);
|
||||
}
|
||||
|
||||
// 客户端信息不存在时重定向到错误页
|
||||
if (!details.client) {
|
||||
redirect(`/oidc/error?error=invalid_client`);
|
||||
}
|
||||
|
||||
// 根据 prompt 类型渲染不同组件
|
||||
if (details.prompt.name === 'login') {
|
||||
return <OidcLoginForm uid={details.uid} client={details.client} />;
|
||||
}
|
||||
|
||||
if (details.prompt.name === 'consent') {
|
||||
const requestedScopes = details.params.scope.split(' ');
|
||||
return (
|
||||
<OidcConsentForm
|
||||
uid={details.uid}
|
||||
client={details.client}
|
||||
requestedScopes={requestedScopes}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 未知 prompt 类型
|
||||
redirect(`/oidc/error?error=unknown_prompt`);
|
||||
}
|
||||
477
apps/web/src/components/oidc-clients/OidcClientCreateDialog.tsx
Normal file
477
apps/web/src/components/oidc-clients/OidcClientCreateDialog.tsx
Normal file
@@ -0,0 +1,477 @@
|
||||
'use client';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useCreateOidcClient } from '@/hooks/useOidcClients';
|
||||
|
||||
// 可选的授权类型
|
||||
const GRANT_TYPE_OPTIONS = [
|
||||
{ value: 'authorization_code', label: '授权码模式' },
|
||||
{ value: 'refresh_token', label: '刷新令牌' },
|
||||
{ value: 'client_credentials', label: '客户端凭证' },
|
||||
] as const;
|
||||
|
||||
// 可选的响应类型
|
||||
const RESPONSE_TYPE_OPTIONS = [
|
||||
{ value: 'code', label: 'code' },
|
||||
{ value: 'id_token', label: 'id_token' },
|
||||
{ value: 'token', label: 'token' },
|
||||
] as const;
|
||||
|
||||
// 可选的 Scope
|
||||
const SCOPE_OPTIONS = [
|
||||
{ value: 'openid', label: 'openid(必需)' },
|
||||
{ value: 'profile', label: 'profile(姓名、头像)' },
|
||||
{ value: 'email', label: 'email(邮箱地址)' },
|
||||
{ value: 'offline_access', label: 'offline_access(刷新令牌)' },
|
||||
] as const;
|
||||
|
||||
// Token 端点认证方式
|
||||
const TOKEN_AUTH_METHOD_OPTIONS = [
|
||||
{ value: 'client_secret_basic', label: 'Basic 认证(推荐)' },
|
||||
{ value: 'client_secret_post', label: 'POST Body' },
|
||||
{ value: 'none', label: '无(公开客户端)' },
|
||||
] as const;
|
||||
|
||||
const createClientSchema = z.object({
|
||||
clientName: z.string().min(1, '请输入客户端名称'),
|
||||
clientUri: z.string().url('请输入有效的 URL').optional().or(z.literal('')),
|
||||
logoUri: z.string().url('请输入有效的 URL').optional().or(z.literal('')),
|
||||
redirectUris: z.string().min(1, '请输入至少一个回调地址'),
|
||||
postLogoutRedirectUris: z.string().optional(),
|
||||
grantTypes: z.array(z.string()).min(1, '请至少选择一种授权类型'),
|
||||
responseTypes: z.array(z.string()).min(1, '请至少选择一种响应类型'),
|
||||
scopes: z.array(z.string()).min(1, '请至少选择 openid'),
|
||||
tokenEndpointAuthMethod: z.string(),
|
||||
applicationType: z.enum(['web', 'native']),
|
||||
isFirstParty: z.boolean(),
|
||||
});
|
||||
|
||||
type CreateClientFormValues = z.infer<typeof createClientSchema>;
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function OidcClientCreateDialog({ open, onOpenChange }: Props) {
|
||||
const [createdSecret, setCreatedSecret] = useState<string | null>(null);
|
||||
const createClient = useCreateOidcClient();
|
||||
|
||||
const form = useForm<CreateClientFormValues>({
|
||||
resolver: zodResolver(createClientSchema),
|
||||
defaultValues: {
|
||||
clientName: '',
|
||||
clientUri: '',
|
||||
logoUri: '',
|
||||
redirectUris: '',
|
||||
postLogoutRedirectUris: '',
|
||||
grantTypes: ['authorization_code', 'refresh_token'],
|
||||
responseTypes: ['code'],
|
||||
scopes: ['openid'],
|
||||
tokenEndpointAuthMethod: 'client_secret_basic',
|
||||
applicationType: 'web',
|
||||
isFirstParty: false,
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (values: CreateClientFormValues) => {
|
||||
try {
|
||||
const result = await createClient.mutateAsync({
|
||||
clientName: values.clientName,
|
||||
clientUri: values.clientUri || undefined,
|
||||
logoUri: values.logoUri || undefined,
|
||||
redirectUris: values.redirectUris.split('\n').filter(Boolean),
|
||||
postLogoutRedirectUris: values.postLogoutRedirectUris
|
||||
? values.postLogoutRedirectUris.split('\n').filter(Boolean)
|
||||
: undefined,
|
||||
grantTypes: values.grantTypes,
|
||||
responseTypes: values.responseTypes,
|
||||
scopes: values.scopes,
|
||||
tokenEndpointAuthMethod: values.tokenEndpointAuthMethod,
|
||||
applicationType: values.applicationType,
|
||||
isFirstParty: values.isFirstParty,
|
||||
});
|
||||
setCreatedSecret(result.clientSecret);
|
||||
toast.success('客户端创建成功');
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : '创建失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
form.reset();
|
||||
setCreatedSecret(null);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const copySecret = () => {
|
||||
if (createdSecret) {
|
||||
navigator.clipboard.writeText(createdSecret);
|
||||
toast.success('密钥已复制');
|
||||
}
|
||||
};
|
||||
|
||||
// 显示创建成功后的密钥
|
||||
if (createdSecret) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>客户端创建成功</DialogTitle>
|
||||
<DialogDescription>
|
||||
请妥善保存以下客户端密钥,此密钥仅显示一次。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-muted rounded-lg">
|
||||
<p className="text-sm text-muted-foreground mb-2">客户端密钥</p>
|
||||
<p className="font-mono text-sm break-all">{createdSecret}</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={copySecret}>
|
||||
复制密钥
|
||||
</Button>
|
||||
<Button onClick={handleClose}>完成</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>创建客户端</DialogTitle>
|
||||
<DialogDescription>创建一个新的 OIDC 客户端应用。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="clientName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>客户端名称</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="我的应用" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="applicationType"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>应用类型</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="web">Web 应用</SelectItem>
|
||||
<SelectItem value="native">原生应用</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="isFirstParty"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>第一方应用</FormLabel>
|
||||
<FormDescription className="text-xs">
|
||||
跳过授权确认
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="redirectUris"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>回调地址</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="https://example.com/callback 每行一个地址"
|
||||
rows={3}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>每行输入一个回调地址</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="clientUri"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>应用主页(可选)</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="https://example.com" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="logoUri"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Logo 地址(可选)</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="https://example.com/logo.png" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="postLogoutRedirectUris"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>登出回调地址(可选)</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="https://example.com/logout 每行一个地址"
|
||||
rows={2}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>用户登出后的跳转地址,每行一个</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="grantTypes"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<FormLabel>授权类型</FormLabel>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{GRANT_TYPE_OPTIONS.map((option) => (
|
||||
<FormField
|
||||
key={option.value}
|
||||
control={form.control}
|
||||
name="grantTypes"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center space-x-2 space-y-0">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value?.includes(option.value)}
|
||||
onCheckedChange={(checked) => {
|
||||
const newValue = checked
|
||||
? [...(field.value || []), option.value]
|
||||
: field.value?.filter((v) => v !== option.value) || [];
|
||||
field.onChange(newValue);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="text-sm font-normal cursor-pointer">
|
||||
{option.label}
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="responseTypes"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<FormLabel>响应类型</FormLabel>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{RESPONSE_TYPE_OPTIONS.map((option) => (
|
||||
<FormField
|
||||
key={option.value}
|
||||
control={form.control}
|
||||
name="responseTypes"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center space-x-2 space-y-0">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value?.includes(option.value)}
|
||||
onCheckedChange={(checked) => {
|
||||
const newValue = checked
|
||||
? [...(field.value || []), option.value]
|
||||
: field.value?.filter((v) => v !== option.value) || [];
|
||||
field.onChange(newValue);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="text-sm font-normal cursor-pointer">
|
||||
{option.label}
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="scopes"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<FormLabel>允许的 Scope</FormLabel>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{SCOPE_OPTIONS.map((option) => (
|
||||
<FormField
|
||||
key={option.value}
|
||||
control={form.control}
|
||||
name="scopes"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center space-x-2 space-y-0">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value?.includes(option.value)}
|
||||
onCheckedChange={(checked) => {
|
||||
const newValue = checked
|
||||
? [...(field.value || []), option.value]
|
||||
: field.value?.filter((v) => v !== option.value) || [];
|
||||
field.onChange(newValue);
|
||||
}}
|
||||
disabled={option.value === 'openid'}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="text-sm font-normal cursor-pointer">
|
||||
{option.label}
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="tokenEndpointAuthMethod"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Token 端点认证方式</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{TOKEN_AUTH_METHOD_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
disabled={createClient.isPending}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" disabled={createClient.isPending}>
|
||||
{createClient.isPending && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
创建
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
478
apps/web/src/components/oidc-clients/OidcClientEditDialog.tsx
Normal file
478
apps/web/src/components/oidc-clients/OidcClientEditDialog.tsx
Normal file
@@ -0,0 +1,478 @@
|
||||
'use client';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import type { OidcClientResponse } from '@seclusion/shared';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useUpdateOidcClient } from '@/hooks/useOidcClients';
|
||||
|
||||
// 可选的授权类型
|
||||
const GRANT_TYPE_OPTIONS = [
|
||||
{ value: 'authorization_code', label: '授权码模式' },
|
||||
{ value: 'refresh_token', label: '刷新令牌' },
|
||||
{ value: 'client_credentials', label: '客户端凭证' },
|
||||
] as const;
|
||||
|
||||
// 可选的响应类型
|
||||
const RESPONSE_TYPE_OPTIONS = [
|
||||
{ value: 'code', label: 'code' },
|
||||
{ value: 'id_token', label: 'id_token' },
|
||||
{ value: 'token', label: 'token' },
|
||||
] as const;
|
||||
|
||||
// 可选的 Scope
|
||||
const SCOPE_OPTIONS = [
|
||||
{ value: 'openid', label: 'openid(必需)' },
|
||||
{ value: 'profile', label: 'profile(姓名、头像)' },
|
||||
{ value: 'email', label: 'email(邮箱地址)' },
|
||||
{ value: 'offline_access', label: 'offline_access(刷新令牌)' },
|
||||
] as const;
|
||||
|
||||
// Token 端点认证方式
|
||||
const TOKEN_AUTH_METHOD_OPTIONS = [
|
||||
{ value: 'client_secret_basic', label: 'Basic 认证(推荐)' },
|
||||
{ value: 'client_secret_post', label: 'POST Body' },
|
||||
{ value: 'none', label: '无(公开客户端)' },
|
||||
] as const;
|
||||
|
||||
const editClientSchema = z.object({
|
||||
clientName: z.string().min(1, '请输入客户端名称'),
|
||||
clientUri: z.string().url('请输入有效的 URL').optional().or(z.literal('')),
|
||||
logoUri: z.string().url('请输入有效的 URL').optional().or(z.literal('')),
|
||||
redirectUris: z.string().min(1, '请输入至少一个回调地址'),
|
||||
postLogoutRedirectUris: z.string().optional(),
|
||||
grantTypes: z.array(z.string()).min(1, '请至少选择一种授权类型'),
|
||||
responseTypes: z.array(z.string()).min(1, '请至少选择一种响应类型'),
|
||||
scopes: z.array(z.string()).min(1, '请至少选择 openid'),
|
||||
tokenEndpointAuthMethod: z.string(),
|
||||
applicationType: z.enum(['web', 'native']),
|
||||
isEnabled: z.boolean(),
|
||||
isFirstParty: z.boolean(),
|
||||
});
|
||||
|
||||
type EditClientFormValues = z.infer<typeof editClientSchema>;
|
||||
|
||||
interface Props {
|
||||
client: OidcClientResponse | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function OidcClientEditDialog({ client, open, onOpenChange }: Props) {
|
||||
const updateClient = useUpdateOidcClient();
|
||||
|
||||
const form = useForm<EditClientFormValues>({
|
||||
resolver: zodResolver(editClientSchema),
|
||||
defaultValues: {
|
||||
clientName: '',
|
||||
clientUri: '',
|
||||
logoUri: '',
|
||||
redirectUris: '',
|
||||
postLogoutRedirectUris: '',
|
||||
grantTypes: ['authorization_code', 'refresh_token'],
|
||||
responseTypes: ['code'],
|
||||
scopes: ['openid'],
|
||||
tokenEndpointAuthMethod: 'client_secret_basic',
|
||||
applicationType: 'web',
|
||||
isEnabled: true,
|
||||
isFirstParty: false,
|
||||
},
|
||||
});
|
||||
|
||||
// 当 client 变化时重置表单
|
||||
useEffect(() => {
|
||||
if (client) {
|
||||
form.reset({
|
||||
clientName: client.clientName,
|
||||
clientUri: client.clientUri || '',
|
||||
logoUri: client.logoUri || '',
|
||||
redirectUris: client.redirectUris.join('\n'),
|
||||
postLogoutRedirectUris: client.postLogoutRedirectUris.join('\n'),
|
||||
grantTypes: client.grantTypes,
|
||||
responseTypes: client.responseTypes,
|
||||
scopes: client.scopes,
|
||||
tokenEndpointAuthMethod: client.tokenEndpointAuthMethod,
|
||||
applicationType: client.applicationType as 'web' | 'native',
|
||||
isEnabled: client.isEnabled,
|
||||
isFirstParty: client.isFirstParty,
|
||||
});
|
||||
}
|
||||
}, [client, form]);
|
||||
|
||||
const onSubmit = async (values: EditClientFormValues) => {
|
||||
if (!client) return;
|
||||
|
||||
try {
|
||||
await updateClient.mutateAsync({
|
||||
id: client.id,
|
||||
data: {
|
||||
clientName: values.clientName,
|
||||
clientUri: values.clientUri || undefined,
|
||||
logoUri: values.logoUri || undefined,
|
||||
redirectUris: values.redirectUris.split('\n').filter(Boolean),
|
||||
postLogoutRedirectUris: values.postLogoutRedirectUris
|
||||
? values.postLogoutRedirectUris.split('\n').filter(Boolean)
|
||||
: undefined,
|
||||
grantTypes: values.grantTypes,
|
||||
responseTypes: values.responseTypes,
|
||||
scopes: values.scopes,
|
||||
tokenEndpointAuthMethod: values.tokenEndpointAuthMethod,
|
||||
applicationType: values.applicationType,
|
||||
isEnabled: values.isEnabled,
|
||||
isFirstParty: values.isFirstParty,
|
||||
},
|
||||
});
|
||||
toast.success('客户端更新成功');
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : '更新失败');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>编辑客户端</DialogTitle>
|
||||
<DialogDescription>
|
||||
客户端 ID: {client?.clientId}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="clientName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>客户端名称</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="applicationType"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>应用类型</FormLabel>
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="web">Web 应用</SelectItem>
|
||||
<SelectItem value="native">原生应用</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="isEnabled"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>启用状态</FormLabel>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="isFirstParty"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>第一方应用</FormLabel>
|
||||
<FormDescription className="text-xs">
|
||||
跳过授权确认页面
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="redirectUris"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>回调地址</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea rows={3} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>每行输入一个回调地址</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="clientUri"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>应用主页(可选)</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="logoUri"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Logo 地址(可选)</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="https://example.com/logo.png" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="postLogoutRedirectUris"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>登出回调地址(可选)</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="https://example.com/logout 每行一个地址"
|
||||
rows={2}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>用户登出后的跳转地址,每行一个</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="grantTypes"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<FormLabel>授权类型</FormLabel>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{GRANT_TYPE_OPTIONS.map((option) => (
|
||||
<FormField
|
||||
key={option.value}
|
||||
control={form.control}
|
||||
name="grantTypes"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center space-x-2 space-y-0">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value?.includes(option.value)}
|
||||
onCheckedChange={(checked) => {
|
||||
const newValue = checked
|
||||
? [...(field.value || []), option.value]
|
||||
: field.value?.filter((v) => v !== option.value) || [];
|
||||
field.onChange(newValue);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="text-sm font-normal cursor-pointer">
|
||||
{option.label}
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="responseTypes"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<FormLabel>响应类型</FormLabel>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{RESPONSE_TYPE_OPTIONS.map((option) => (
|
||||
<FormField
|
||||
key={option.value}
|
||||
control={form.control}
|
||||
name="responseTypes"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center space-x-2 space-y-0">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value?.includes(option.value)}
|
||||
onCheckedChange={(checked) => {
|
||||
const newValue = checked
|
||||
? [...(field.value || []), option.value]
|
||||
: field.value?.filter((v) => v !== option.value) || [];
|
||||
field.onChange(newValue);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="text-sm font-normal cursor-pointer">
|
||||
{option.label}
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="scopes"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<FormLabel>允许的 Scope</FormLabel>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{SCOPE_OPTIONS.map((option) => (
|
||||
<FormField
|
||||
key={option.value}
|
||||
control={form.control}
|
||||
name="scopes"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center space-x-2 space-y-0">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value?.includes(option.value)}
|
||||
onCheckedChange={(checked) => {
|
||||
const newValue = checked
|
||||
? [...(field.value || []), option.value]
|
||||
: field.value?.filter((v) => v !== option.value) || [];
|
||||
field.onChange(newValue);
|
||||
}}
|
||||
disabled={option.value === 'openid'}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="text-sm font-normal cursor-pointer">
|
||||
{option.label}
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="tokenEndpointAuthMethod"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Token 端点认证方式</FormLabel>
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{TOKEN_AUTH_METHOD_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={updateClient.isPending}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" disabled={updateClient.isPending}>
|
||||
{updateClient.isPending && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
保存
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
331
apps/web/src/components/oidc-clients/OidcClientsTable.tsx
Normal file
331
apps/web/src/components/oidc-clients/OidcClientsTable.tsx
Normal file
@@ -0,0 +1,331 @@
|
||||
'use client';
|
||||
|
||||
import type { OidcClientResponse } from '@seclusion/shared';
|
||||
import { formatDate } from '@seclusion/shared';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import { Copy, Key, MoreHorizontal, Pencil, Trash2 } from 'lucide-react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { OidcClientCreateDialog } from './OidcClientCreateDialog';
|
||||
import { OidcClientEditDialog } from './OidcClientEditDialog';
|
||||
|
||||
import {
|
||||
DataTable,
|
||||
DataTableColumnHeader,
|
||||
type PaginationState,
|
||||
type SortingParams,
|
||||
} from '@/components/shared/DataTable';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { PAGINATION } from '@/config/constants';
|
||||
import {
|
||||
useDeleteOidcClient,
|
||||
useOidcClients,
|
||||
useRegenerateOidcClientSecret,
|
||||
} from '@/hooks/useOidcClients';
|
||||
|
||||
interface ClientActionsProps {
|
||||
client: OidcClientResponse;
|
||||
onEdit: (client: OidcClientResponse) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onRegenerateSecret: (id: string) => void;
|
||||
}
|
||||
|
||||
function ClientActions({
|
||||
client,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onRegenerateSecret,
|
||||
}: ClientActionsProps) {
|
||||
const copyClientId = () => {
|
||||
navigator.clipboard.writeText(client.clientId);
|
||||
toast.success('客户端 ID 已复制');
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">打开菜单</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>操作</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={copyClientId}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
复制客户端 ID
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onEdit(client)}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
编辑
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onRegenerateSecret(client.id)}>
|
||||
<Key className="mr-2 h-4 w-4" />
|
||||
重新生成密钥
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => onDelete(client.id)}
|
||||
className="text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
删除
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export function OidcClientsTable() {
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
page: PAGINATION.DEFAULT_PAGE,
|
||||
pageSize: PAGINATION.DEFAULT_PAGE_SIZE,
|
||||
});
|
||||
const [sorting, setSorting] = useState<SortingParams>({});
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||
const [clientToEdit, setClientToEdit] = useState<OidcClientResponse | null>(null);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [clientToDelete, setClientToDelete] = useState<string | null>(null);
|
||||
const [secretDialogOpen, setSecretDialogOpen] = useState(false);
|
||||
const [clientToRegenerate, setClientToRegenerate] = useState<string | null>(null);
|
||||
|
||||
const { data, isLoading, refetch } = useOidcClients({
|
||||
page: pagination.page,
|
||||
pageSize: pagination.pageSize,
|
||||
...sorting,
|
||||
});
|
||||
|
||||
const deleteClient = useDeleteOidcClient();
|
||||
const regenerateSecret = useRegenerateOidcClientSecret();
|
||||
|
||||
const handleEdit = useCallback((client: OidcClientResponse) => {
|
||||
setClientToEdit(client);
|
||||
setEditDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleDelete = useCallback((id: string) => {
|
||||
setClientToDelete(id);
|
||||
setDeleteDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const confirmDelete = useCallback(async () => {
|
||||
if (!clientToDelete) return;
|
||||
try {
|
||||
await deleteClient.mutateAsync(clientToDelete);
|
||||
toast.success('客户端已删除');
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : '删除失败');
|
||||
} finally {
|
||||
setDeleteDialogOpen(false);
|
||||
setClientToDelete(null);
|
||||
}
|
||||
}, [clientToDelete, deleteClient]);
|
||||
|
||||
const handleRegenerateSecret = useCallback((id: string) => {
|
||||
setClientToRegenerate(id);
|
||||
setSecretDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const confirmRegenerateSecret = useCallback(async () => {
|
||||
if (!clientToRegenerate) return;
|
||||
try {
|
||||
const result = await regenerateSecret.mutateAsync(clientToRegenerate);
|
||||
toast.success(
|
||||
<div>
|
||||
<p>密钥已重新生成</p>
|
||||
<p className="font-mono text-xs mt-1 break-all">{result.clientSecret}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">请妥善保存,此密钥仅显示一次</p>
|
||||
</div>,
|
||||
{ duration: 10000 }
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : '重新生成失败');
|
||||
} finally {
|
||||
setSecretDialogOpen(false);
|
||||
setClientToRegenerate(null);
|
||||
}
|
||||
}, [clientToRegenerate, regenerateSecret]);
|
||||
|
||||
const handlePaginationChange = useCallback((newPagination: PaginationState) => {
|
||||
setPagination(newPagination);
|
||||
}, []);
|
||||
|
||||
const handleSortingChange = useCallback((newSorting: SortingParams) => {
|
||||
setSorting(newSorting);
|
||||
setPagination((prev) => ({ ...prev, page: 1 }));
|
||||
}, []);
|
||||
|
||||
const columns: ColumnDef<OidcClientResponse>[] = [
|
||||
{
|
||||
accessorKey: 'clientName',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader
|
||||
title="客户端名称"
|
||||
sortKey={column.id}
|
||||
sorting={sorting}
|
||||
onSortingChange={handleSortingChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'clientId',
|
||||
header: '客户端 ID',
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs">{row.original.clientId}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'applicationType',
|
||||
header: '类型',
|
||||
cell: ({ row }) => (
|
||||
<Badge variant="outline">
|
||||
{row.original.applicationType === 'web' ? 'Web' : 'Native'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'isEnabled',
|
||||
header: '状态',
|
||||
cell: ({ row }) => (
|
||||
<Badge variant={row.original.isEnabled ? 'default' : 'secondary'}>
|
||||
{row.original.isEnabled ? '启用' : '禁用'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'isFirstParty',
|
||||
header: '第一方',
|
||||
cell: ({ row }) => (
|
||||
<Badge variant={row.original.isFirstParty ? 'default' : 'secondary'}>
|
||||
{row.original.isFirstParty ? '是' : '否'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'createdAt',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader
|
||||
title="创建时间"
|
||||
sortKey={column.id}
|
||||
sorting={sorting}
|
||||
onSortingChange={handleSortingChange}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) =>
|
||||
formatDate(new Date(row.original.createdAt), 'YYYY-MM-DD HH:mm'),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: '操作',
|
||||
cell: ({ row }) => (
|
||||
<ClientActions
|
||||
client={row.original}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
onRegenerateSecret={handleRegenerateSecret}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Button onClick={() => setCreateDialogOpen(true)}>创建客户端</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => refetch()}>
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data?.items ?? []}
|
||||
pagination={pagination}
|
||||
paginationInfo={
|
||||
data ? { total: data.total, totalPages: data.totalPages } : undefined
|
||||
}
|
||||
onPaginationChange={handlePaginationChange}
|
||||
manualPagination
|
||||
sorting={sorting}
|
||||
onSortingChange={handleSortingChange}
|
||||
manualSorting
|
||||
isLoading={isLoading}
|
||||
emptyMessage="暂无客户端"
|
||||
/>
|
||||
|
||||
{/* 创建客户端弹窗 */}
|
||||
<OidcClientCreateDialog
|
||||
open={createDialogOpen}
|
||||
onOpenChange={setCreateDialogOpen}
|
||||
/>
|
||||
|
||||
{/* 编辑客户端弹窗 */}
|
||||
<OidcClientEditDialog
|
||||
client={clientToEdit}
|
||||
open={editDialogOpen}
|
||||
onOpenChange={setEditDialogOpen}
|
||||
/>
|
||||
|
||||
{/* 删除确认弹窗 */}
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确定要删除该客户端吗?删除后使用该客户端的应用将无法正常工作。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={confirmDelete}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* 重新生成密钥确认弹窗 */}
|
||||
<AlertDialog open={secretDialogOpen} onOpenChange={setSecretDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>重新生成密钥</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确定要重新生成客户端密钥吗?旧密钥将立即失效,使用旧密钥的应用需要更新配置。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={confirmRegenerateSecret}>
|
||||
确认
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
3
apps/web/src/components/oidc-clients/index.ts
Normal file
3
apps/web/src/components/oidc-clients/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { OidcClientsTable } from './OidcClientsTable';
|
||||
export { OidcClientCreateDialog } from './OidcClientCreateDialog';
|
||||
export { OidcClientEditDialog } from './OidcClientEditDialog';
|
||||
@@ -21,6 +21,8 @@ export const API_ENDPOINTS = {
|
||||
TEACHERS: '/teachers',
|
||||
CLASSES: '/classes',
|
||||
STUDENTS: '/students',
|
||||
// OIDC 交互(使用 Cookie 而非 JWT 认证)
|
||||
OIDC_INTERACTION: '/oidc-interaction',
|
||||
} as const;
|
||||
|
||||
// 分页默认值
|
||||
|
||||
88
apps/web/src/hooks/useOidcClients.ts
Normal file
88
apps/web/src/hooks/useOidcClients.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import type {
|
||||
CreateOidcClientRequest,
|
||||
PaginationParams,
|
||||
UpdateOidcClientRequest,
|
||||
} from '@seclusion/shared';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { oidcClientService } from '@/services/oidc-client.service';
|
||||
|
||||
const QUERY_KEY = 'oidc-clients';
|
||||
|
||||
/**
|
||||
* 获取 OIDC 客户端列表
|
||||
*/
|
||||
export function useOidcClients(params?: PaginationParams) {
|
||||
return useQuery({
|
||||
queryKey: [QUERY_KEY, params],
|
||||
queryFn: () => oidcClientService.getList(params),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 OIDC 客户端详情
|
||||
*/
|
||||
export function useOidcClient(id: string) {
|
||||
return useQuery({
|
||||
queryKey: [QUERY_KEY, id],
|
||||
queryFn: () => oidcClientService.getById(id),
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 OIDC 客户端
|
||||
*/
|
||||
export function useCreateOidcClient() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateOidcClientRequest) => oidcClientService.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 OIDC 客户端
|
||||
*/
|
||||
export function useUpdateOidcClient() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: UpdateOidcClientRequest }) =>
|
||||
oidcClientService.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除 OIDC 客户端
|
||||
*/
|
||||
export function useDeleteOidcClient() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => oidcClientService.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新生成客户端密钥
|
||||
*/
|
||||
export function useRegenerateOidcClientSecret() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => oidcClientService.regenerateSecret(id),
|
||||
onSuccess: (_data, id) => {
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, id] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export { authService } from './auth.service';
|
||||
export { userService, type GetUsersParams } from './user.service';
|
||||
46
apps/web/src/services/oidc-client.service.ts
Normal file
46
apps/web/src/services/oidc-client.service.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type {
|
||||
CreateOidcClientRequest,
|
||||
OidcClientResponse,
|
||||
UpdateOidcClientRequest,
|
||||
} from '@seclusion/shared';
|
||||
import type { PaginatedResponse, PaginationParams } from '@seclusion/shared';
|
||||
|
||||
import { http } from '@/lib/http';
|
||||
|
||||
const BASE_URL = '/oidc-clients';
|
||||
|
||||
export const oidcClientService = {
|
||||
/**
|
||||
* 获取客户端列表
|
||||
*/
|
||||
getList: (params?: PaginationParams) =>
|
||||
http.get<PaginatedResponse<OidcClientResponse>>(BASE_URL, { params }),
|
||||
|
||||
/**
|
||||
* 获取客户端详情
|
||||
*/
|
||||
getById: (id: string) => http.get<OidcClientResponse>(`${BASE_URL}/${id}`),
|
||||
|
||||
/**
|
||||
* 创建客户端
|
||||
*/
|
||||
create: (data: CreateOidcClientRequest) =>
|
||||
http.post<OidcClientResponse & { clientSecret: string }>(BASE_URL, data),
|
||||
|
||||
/**
|
||||
* 更新客户端
|
||||
*/
|
||||
update: (id: string, data: UpdateOidcClientRequest) =>
|
||||
http.patch<OidcClientResponse>(`${BASE_URL}/${id}`, data),
|
||||
|
||||
/**
|
||||
* 删除客户端
|
||||
*/
|
||||
delete: (id: string) => http.delete(`${BASE_URL}/${id}`),
|
||||
|
||||
/**
|
||||
* 重新生成客户端密钥
|
||||
*/
|
||||
regenerateSecret: (id: string) =>
|
||||
http.post<{ clientSecret: string }>(`${BASE_URL}/${id}/regenerate-secret`),
|
||||
};
|
||||
71
apps/web/src/services/oidc-interaction.service.ts
Normal file
71
apps/web/src/services/oidc-interaction.service.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* OIDC 交互服务
|
||||
*
|
||||
* 注意:此服务使用原生 fetch 而非标准 http 客户端,因为:
|
||||
* - OIDC 交互使用 Cookie 而非 JWT 认证
|
||||
* - 需要 `credentials: 'include'` 让浏览器自动管理 Cookie
|
||||
*/
|
||||
import type {
|
||||
OidcConsentRequest,
|
||||
OidcInteractionRedirectResponse,
|
||||
OidcLoginRequest,
|
||||
} from '@seclusion/shared';
|
||||
|
||||
import { API_ENDPOINTS } from '@/config/constants';
|
||||
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
|
||||
const BASE_URL = `${API_BASE_URL}${API_ENDPOINTS.OIDC_INTERACTION}`;
|
||||
|
||||
/** 发送 OIDC 交互请求(使用 Cookie 认证) */
|
||||
async function fetchWithCredentials<T>(
|
||||
url: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
const res = await fetch(url, {
|
||||
...options,
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json().catch(() => ({ message: '请求失败' }));
|
||||
throw new Error(error.message || '请求失败');
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export const oidcInteractionService = {
|
||||
// 提交 OIDC 登录
|
||||
submitLogin: (
|
||||
uid: string,
|
||||
data: OidcLoginRequest
|
||||
): Promise<OidcInteractionRedirectResponse> => {
|
||||
return fetchWithCredentials<OidcInteractionRedirectResponse>(
|
||||
`${BASE_URL}/${uid}/login`,
|
||||
{ method: 'POST', body: JSON.stringify(data) }
|
||||
);
|
||||
},
|
||||
|
||||
// 提交授权确认
|
||||
submitConsent: (
|
||||
uid: string,
|
||||
data: OidcConsentRequest
|
||||
): Promise<OidcInteractionRedirectResponse> => {
|
||||
return fetchWithCredentials<OidcInteractionRedirectResponse>(
|
||||
`${BASE_URL}/${uid}/confirm`,
|
||||
{ method: 'POST', body: JSON.stringify(data) }
|
||||
);
|
||||
},
|
||||
|
||||
// 中止授权
|
||||
abort: (uid: string): Promise<OidcInteractionRedirectResponse> => {
|
||||
return fetchWithCredentials<OidcInteractionRedirectResponse>(
|
||||
`${BASE_URL}/${uid}/abort`,
|
||||
{ method: 'POST' }
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -506,9 +506,11 @@ export class OidcClientController {
|
||||
|
||||
## 四、OIDC 端点说明
|
||||
|
||||
Issuer URL 为 `http://localhost:4000/oidc`,所有标准 OIDC 端点都在 `/oidc` 路径下:
|
||||
|
||||
| 端点 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `/.well-known/openid-configuration` | GET | OIDC 发现文档 |
|
||||
| `/oidc/.well-known/openid-configuration` | GET | OIDC 发现文档 |
|
||||
| `/oidc/authorize` | GET | 授权端点 |
|
||||
| `/oidc/token` | POST | 令牌端点 |
|
||||
| `/oidc/userinfo` | GET/POST | 用户信息端点 |
|
||||
@@ -516,9 +518,15 @@ export class OidcClientController {
|
||||
| `/oidc/revoke` | POST | 令牌撤销 |
|
||||
| `/oidc/introspect` | POST | 令牌内省 |
|
||||
| `/oidc/logout` | GET/POST | 登出端点 |
|
||||
| `/oidc/interaction/:uid` | GET | 获取交互详情 |
|
||||
| `/oidc/interaction/:uid/login` | POST | 提交登录 |
|
||||
| `/oidc/interaction/:uid/confirm` | POST | 提交授权确认 |
|
||||
|
||||
自定义交互 API 端点在 `/oidc-interaction` 路径下(避免与 oidc-provider 冲突):
|
||||
|
||||
| 端点 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `/oidc-interaction/:uid` | GET | 获取交互详情 |
|
||||
| `/oidc-interaction/:uid/login` | POST | 提交登录 |
|
||||
| `/oidc-interaction/:uid/confirm` | POST | 提交授权确认 |
|
||||
| `/oidc-interaction/:uid/abort` | POST | 中止授权 |
|
||||
|
||||
## 五、前端改动
|
||||
|
||||
@@ -880,8 +888,8 @@ export const OidcScopeDescriptions: Record<string, string> = {
|
||||
|
||||
```bash
|
||||
# ----- OIDC Provider 配置 -----
|
||||
# OIDC 签发者 URL(必须是可公开访问的 HTTPS URL)
|
||||
OIDC_ISSUER=http://localhost:4000
|
||||
# OIDC 签发者 URL(必须是可公开访问的 URL,包含 /oidc 路径)
|
||||
OIDC_ISSUER=http://localhost:4000/oidc
|
||||
# OIDC Cookie 签名密钥(生产环境必须修改)
|
||||
OIDC_COOKIE_SECRET=your-oidc-cookie-secret-change-in-production
|
||||
# OIDC JWKS 私钥(RS256,PEM 格式,Base64 编码)
|
||||
@@ -939,10 +947,87 @@ OIDC_JWKS_PRIVATE_KEY=
|
||||
3. API 文档完善
|
||||
4. 使用文档
|
||||
|
||||
## 十、关键文件清单
|
||||
## 十、NestJS 集成要点
|
||||
|
||||
### 10.1 Issuer URL 配置
|
||||
|
||||
Issuer URL 可以包含路径前缀,如 `http://localhost:4000/oidc`:
|
||||
|
||||
- 发现文档路径:`/oidc/.well-known/openid-configuration`
|
||||
- 授权端点:`/oidc/authorize`
|
||||
- 令牌端点:`/oidc/token`
|
||||
|
||||
这符合 [RFC 8414](https://tools.ietf.org/html/rfc8414) 规范,`.well-known` 路径是相对于 Issuer URL 的。
|
||||
|
||||
### 10.2 中间件挂载方式
|
||||
|
||||
**问题**:NestJS Controller 的 `@All('*')` 无法正确捕获 oidc-provider 的所有路由(如 `.well-known` 路径)。
|
||||
|
||||
**解决方案**:使用 Express 原生方式在 `main.ts` 中挂载:
|
||||
|
||||
```typescript
|
||||
// main.ts
|
||||
const oidcService = app.get(OidcService);
|
||||
const provider = oidcService.getProvider();
|
||||
if (provider) {
|
||||
const expressApp = app.getHttpAdapter().getInstance();
|
||||
expressApp.use('/oidc', provider.callback());
|
||||
}
|
||||
```
|
||||
|
||||
### 10.3 初始化时机(关键)
|
||||
|
||||
**问题**:`onModuleInit()` 生命周期钩子在 `NestFactory.create()` 返回后执行,但中间件需要在路由注册前挂载。
|
||||
|
||||
**解决方案**:将 Provider 初始化从 `onModuleInit()` 移到**构造函数**中:
|
||||
|
||||
```typescript
|
||||
// ❌ 错误:onModuleInit 执行太晚,中间件挂载时 provider 还是 undefined
|
||||
@Injectable()
|
||||
export class OidcService implements OnModuleInit {
|
||||
async onModuleInit() {
|
||||
this.provider = new Provider(issuer, config);
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ 正确:构造函数中同步初始化,确保 app.get() 时 provider 已就绪
|
||||
@Injectable()
|
||||
export class OidcService {
|
||||
constructor(...deps) {
|
||||
this.initializeProvider();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 10.4 挂载顺序
|
||||
|
||||
中间件必须在 NestJS 路由注册之前挂载,正确的启动顺序:
|
||||
|
||||
```
|
||||
NestFactory.create()
|
||||
→ 挂载 oidc-provider 中间件(Express 原生方式)
|
||||
→ app.useGlobalPipes()
|
||||
→ app.enableCors()
|
||||
→ app.listen()
|
||||
```
|
||||
|
||||
### 10.5 与 NestJS Controller 共存
|
||||
|
||||
为避免路由冲突,oidc-provider 和 NestJS Controller 使用不同的路径前缀:
|
||||
|
||||
| 处理者 | 路径前缀 | 端点 |
|
||||
|--------|----------|------|
|
||||
| oidc-provider | `/oidc` | 标准 OIDC 端点:`.well-known`、`/authorize`、`/token`、`/userinfo`、`/jwks` 等 |
|
||||
| NestJS Controller | `/oidc-interaction` | 自定义交互 API:`/:uid`、`/:uid/login`、`/:uid/confirm`、`/:uid/abort` |
|
||||
| NestJS Controller | `/oidc-clients` | 客户端管理 API |
|
||||
|
||||
oidc-provider 中间件挂载在 `/oidc` 路径下,会先执行。自定义交互 API 放在 `/oidc-interaction` 路径下,由 NestJS 路由处理,避免被 oidc-provider 拦截。
|
||||
|
||||
## 十一、关键文件清单
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `apps/api/src/main.ts` | 应用入口,挂载 oidc-provider 中间件 |
|
||||
| `apps/api/prisma/schema.prisma` | 添加 OIDC 相关数据模型 |
|
||||
| `apps/api/src/oidc/` | OIDC 模块目录 |
|
||||
| `apps/api/src/auth/auth.service.ts` | 参考现有认证逻辑 |
|
||||
@@ -950,7 +1035,7 @@ OIDC_JWKS_PRIVATE_KEY=
|
||||
| `apps/web/src/app/(dashboard)/oidc-clients/` | OIDC 客户端管理页面 |
|
||||
| `packages/shared/src/types/oidc.ts` | OIDC 共享类型定义 |
|
||||
|
||||
## 十一、参考资料
|
||||
## 十二、参考资料
|
||||
|
||||
- [panva/node-oidc-provider](https://github.com/panva/node-oidc-provider)
|
||||
- [node-oidc-provider Documentation](https://github.com/panva/node-oidc-provider/blob/main/docs/README.md)
|
||||
|
||||
@@ -257,3 +257,24 @@ export {
|
||||
type UserRoleResponse,
|
||||
type UserWithRolesResponse,
|
||||
} from './permission';
|
||||
|
||||
// ==================== OIDC Provider 相关类型 ====================
|
||||
|
||||
export {
|
||||
// Scope 描述
|
||||
OidcScopeDescriptions,
|
||||
// 客户端类型
|
||||
type OidcClientResponse,
|
||||
type CreateOidcClientRequest,
|
||||
type UpdateOidcClientRequest,
|
||||
/** @deprecated 使用 CreateOidcClientRequest */
|
||||
type CreateOidcClientDto,
|
||||
/** @deprecated 使用 UpdateOidcClientRequest */
|
||||
type UpdateOidcClientDto,
|
||||
// 交互类型
|
||||
type OidcInteractionDetails,
|
||||
type OidcInteractionRedirectResponse,
|
||||
type OidcLoginRequest,
|
||||
type OidcConsentRequest,
|
||||
} from './oidc';
|
||||
|
||||
|
||||
114
packages/shared/src/types/oidc.ts
Normal file
114
packages/shared/src/types/oidc.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
// ==================== OIDC 客户端类型 ====================
|
||||
|
||||
/** OIDC 客户端响应 */
|
||||
export interface OidcClientResponse {
|
||||
id: string;
|
||||
clientId: string;
|
||||
clientName: string;
|
||||
clientUri: string | null;
|
||||
logoUri: string | null;
|
||||
redirectUris: string[];
|
||||
postLogoutRedirectUris: string[];
|
||||
grantTypes: string[];
|
||||
responseTypes: string[];
|
||||
scopes: string[];
|
||||
tokenEndpointAuthMethod: string;
|
||||
applicationType: string;
|
||||
isEnabled: boolean;
|
||||
isFirstParty: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** 创建 OIDC 客户端请求 */
|
||||
export interface CreateOidcClientRequest {
|
||||
clientName: string;
|
||||
clientUri?: string;
|
||||
logoUri?: string;
|
||||
redirectUris: string[];
|
||||
postLogoutRedirectUris?: string[];
|
||||
grantTypes?: string[];
|
||||
responseTypes?: string[];
|
||||
scopes?: string[];
|
||||
tokenEndpointAuthMethod?: string;
|
||||
applicationType?: string;
|
||||
isFirstParty?: boolean;
|
||||
}
|
||||
|
||||
/** @deprecated 使用 CreateOidcClientRequest */
|
||||
export type CreateOidcClientDto = CreateOidcClientRequest;
|
||||
|
||||
/** 更新 OIDC 客户端请求 */
|
||||
export interface UpdateOidcClientRequest {
|
||||
clientName?: string;
|
||||
clientUri?: string;
|
||||
logoUri?: string;
|
||||
redirectUris?: string[];
|
||||
postLogoutRedirectUris?: string[];
|
||||
grantTypes?: string[];
|
||||
responseTypes?: string[];
|
||||
scopes?: string[];
|
||||
tokenEndpointAuthMethod?: string;
|
||||
applicationType?: string;
|
||||
isEnabled?: boolean;
|
||||
isFirstParty?: boolean;
|
||||
}
|
||||
|
||||
/** @deprecated 使用 UpdateOidcClientRequest */
|
||||
export type UpdateOidcClientDto = UpdateOidcClientRequest;
|
||||
|
||||
// ==================== OIDC 交互类型 ====================
|
||||
|
||||
/** OIDC 交互详情 */
|
||||
export interface OidcInteractionDetails {
|
||||
uid: string;
|
||||
prompt: {
|
||||
name: 'login' | 'consent';
|
||||
details?: Record<string, unknown>;
|
||||
};
|
||||
params: {
|
||||
client_id: string;
|
||||
redirect_uri: string;
|
||||
scope: string;
|
||||
response_type?: string;
|
||||
state?: string;
|
||||
nonce?: string;
|
||||
};
|
||||
client: {
|
||||
clientId: string;
|
||||
clientName: string;
|
||||
logoUri?: string;
|
||||
clientUri?: string;
|
||||
} | null;
|
||||
session?: {
|
||||
accountId: string;
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== OIDC Scope 描述 ====================
|
||||
|
||||
/** Scope 描述映射 */
|
||||
export const OidcScopeDescriptions: Record<string, string> = {
|
||||
openid: '基本身份信息',
|
||||
profile: '个人资料(姓名、头像)',
|
||||
email: '邮箱地址',
|
||||
offline_access: '离线访问(刷新令牌)',
|
||||
};
|
||||
|
||||
// ==================== OIDC 交互 API 类型 ====================
|
||||
|
||||
/** OIDC 交互成功响应 */
|
||||
export interface OidcInteractionRedirectResponse {
|
||||
redirectTo: string;
|
||||
}
|
||||
|
||||
/** OIDC 登录请求 */
|
||||
export interface OidcLoginRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
/** OIDC 授权确认请求 */
|
||||
export interface OidcConsentRequest {
|
||||
scopes: string[];
|
||||
}
|
||||
312
pnpm-lock.yaml
generated
312
pnpm-lock.yaml
generated
@@ -56,6 +56,9 @@ importers:
|
||||
'@nestjs/swagger':
|
||||
specifier: ^8.1.0
|
||||
version: 8.1.1(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.20)(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)
|
||||
'@paralleldrive/cuid2':
|
||||
specifier: ^3.0.6
|
||||
version: 3.0.6
|
||||
'@prisma/client':
|
||||
specifier: ^6.1.0
|
||||
version: 6.19.1(prisma@6.19.1(typescript@5.9.3))(typescript@5.9.3)
|
||||
@@ -83,6 +86,9 @@ importers:
|
||||
nodemailer:
|
||||
specifier: ^7.0.12
|
||||
version: 7.0.12
|
||||
oidc-provider:
|
||||
specifier: ^9.6.0
|
||||
version: 9.6.0
|
||||
passport:
|
||||
specifier: ^0.7.0
|
||||
version: 0.7.0
|
||||
@@ -135,6 +141,9 @@ importers:
|
||||
'@types/nodemailer':
|
||||
specifier: ^7.0.5
|
||||
version: 7.0.5
|
||||
'@types/oidc-provider':
|
||||
specifier: ^9.5.0
|
||||
version: 9.5.0
|
||||
'@types/passport-jwt':
|
||||
specifier: ^4.0.1
|
||||
version: 4.0.1
|
||||
@@ -1184,6 +1193,16 @@ packages:
|
||||
'@jridgewell/trace-mapping@0.3.9':
|
||||
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
|
||||
|
||||
'@koa/cors@5.0.0':
|
||||
resolution: {integrity: sha512-x/iUDjcS90W69PryLDIMgFyV21YLTnG9zOpPXS7Bkt2b8AsY3zZsIpOLBkYr9fBcF3HbkKaER5hOBZLfpLgYNw==}
|
||||
engines: {node: '>= 14.0.0'}
|
||||
|
||||
'@koa/router@15.2.0':
|
||||
resolution: {integrity: sha512-7YUhq4W83cybfNa4E7JqJpWzoCTSvbnFltkvRaUaUX1ybFzlUoLNY1SqT8XmIAO6nGbFrev+FvJHw4mL+4WhuQ==}
|
||||
engines: {node: '>= 20'}
|
||||
peerDependencies:
|
||||
koa: ^2.0.0 || ^3.0.0
|
||||
|
||||
'@ljharb/through@2.3.14':
|
||||
resolution: {integrity: sha512-ajBvlKpWucBB17FuQYUShqpqy8GRgYEpJW0vWJbUu1CV9lWyrDCapy0lScU8T8Z6qn49sSwJB3+M+evYIdGg+A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -1370,6 +1389,10 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@noble/hashes@2.0.1':
|
||||
resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==}
|
||||
engines: {node: '>= 20.19.0'}
|
||||
|
||||
'@nodelib/fs.scandir@2.1.5':
|
||||
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -1391,6 +1414,10 @@ packages:
|
||||
engines: {node: '>=8.0.0', npm: '>=5.0.0'}
|
||||
hasBin: true
|
||||
|
||||
'@paralleldrive/cuid2@3.0.6':
|
||||
resolution: {integrity: sha512-ujtxTTvr4fwPrzuQT7o6VLKs5BzdWetR9+/zRQ0SyK9hVIwZQllEccxgcHYXN6I3Z429y1yg3F6+uiVxMDPrLQ==}
|
||||
hasBin: true
|
||||
|
||||
'@pkgjs/parseargs@0.11.0':
|
||||
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
|
||||
engines: {node: '>=14'}
|
||||
@@ -2373,6 +2400,9 @@ packages:
|
||||
'@tybys/wasm-util@0.10.1':
|
||||
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
|
||||
|
||||
'@types/accepts@1.3.7':
|
||||
resolution: {integrity: sha512-Pay9fq2lM2wXPWbteBsRAGiWH2hig4ZE2asK+mm7kUzlxRTfL961rj89I6zV/E3PcIkDqyuBEcMxFT7rccugeQ==}
|
||||
|
||||
'@types/babel__core@7.20.5':
|
||||
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
|
||||
|
||||
@@ -2394,6 +2424,12 @@ packages:
|
||||
'@types/connect@3.4.38':
|
||||
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
|
||||
|
||||
'@types/content-disposition@0.5.9':
|
||||
resolution: {integrity: sha512-8uYXI3Gw35MhiVYhG3s295oihrxRyytcRHjSjqnqZVDDy/xcGBRny7+Xj1Wgfhv5QzRtN2hB2dVRBUX9XW3UcQ==}
|
||||
|
||||
'@types/cookies@0.9.2':
|
||||
resolution: {integrity: sha512-1AvkDdZM2dbyFybL4fxpuNCaWyv//0AwsuUk2DWeXyM1/5ZKm6W3z6mQi24RZ4l2ucY+bkSHzbDVpySqPGuV8A==}
|
||||
|
||||
'@types/eslint-scope@3.7.7':
|
||||
resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==}
|
||||
|
||||
@@ -2415,6 +2451,9 @@ packages:
|
||||
'@types/graceful-fs@4.1.9':
|
||||
resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==}
|
||||
|
||||
'@types/http-assert@1.5.6':
|
||||
resolution: {integrity: sha512-TTEwmtjgVbYAzZYWyeHPrrtWnfVkm8tQkP8P21uQifPgMRgjrow3XDEYqucuC8SKZJT7pUnhU/JymvjggxO9vw==}
|
||||
|
||||
'@types/http-errors@2.0.5':
|
||||
resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==}
|
||||
|
||||
@@ -2445,6 +2484,15 @@ packages:
|
||||
'@types/jsonwebtoken@9.0.5':
|
||||
resolution: {integrity: sha512-VRLSGzik+Unrup6BsouBeHsf4d1hOEgYWTm/7Nmw1sXoN1+tRly/Gy/po3yeahnP4jfnQWWAhQAqcNfH7ngOkA==}
|
||||
|
||||
'@types/keygrip@1.0.6':
|
||||
resolution: {integrity: sha512-lZuNAY9xeJt7Bx4t4dx0rYCDqGPW8RXhQZK1td7d4H6E9zYbLoOtjBvfwdTKpsyxQI/2jv+armjX/RW+ZNpXOQ==}
|
||||
|
||||
'@types/koa-compose@3.2.9':
|
||||
resolution: {integrity: sha512-BroAZ9FTvPiCy0Pi8tjD1OfJ7bgU1gQf0eR6e1Vm+JJATy9eKOG3hQMFtMciMawiSOVnLMdmUOC46s7HBhSTsA==}
|
||||
|
||||
'@types/koa@3.0.1':
|
||||
resolution: {integrity: sha512-VkB6WJUQSe0zBpR+Q7/YIUESGp5wPHcaXr0xueU5W0EOUWtlSbblsl+Kl31lyRQ63nIILh0e/7gXjQ09JXJIHw==}
|
||||
|
||||
'@types/liftoff@4.0.3':
|
||||
resolution: {integrity: sha512-UgbL2kR5pLrWICvr8+fuSg0u43LY250q7ZMkC+XKC3E+rs/YBDEnQIzsnhU5dYsLlwMi3R75UvCL87pObP1sxw==}
|
||||
|
||||
@@ -2466,6 +2514,9 @@ packages:
|
||||
'@types/nodemailer@7.0.5':
|
||||
resolution: {integrity: sha512-7WtR4MFJUNN2UFy0NIowBRJswj5KXjXDhlZY43Hmots5eGu5q/dTeFd/I6GgJA/qj3RqO6dDy4SvfcV3fOVeIA==}
|
||||
|
||||
'@types/oidc-provider@9.5.0':
|
||||
resolution: {integrity: sha512-eEzCRVTSqIHD9Bo/qRJ4XQWQ5Z/zBcG+Z2cGJluRsSuWx1RJihqRyPxhIEpMXTwPzHYRTQkVp7hwisQOwzzSAg==}
|
||||
|
||||
'@types/passport-jwt@4.0.1':
|
||||
resolution: {integrity: sha512-Y0Ykz6nWP4jpxgEUYq8NoVZeCQPo1ZndJLfapI249g1jHChvRfZRO/LS3tqu26YgAS/laI1qx98sYGz0IalRXQ==}
|
||||
|
||||
@@ -2963,6 +3014,9 @@ packages:
|
||||
resolution: {integrity: sha512-AGBHOG5hPYZ5Xl9KXzU5iKq9516yEmvCKDg3ecP5kX2aB6UqTeXZxk2ELnDgDm6BQSMlLt9rDB4LoSMx0rYwww==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
|
||||
bignumber.js@9.3.1:
|
||||
resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==}
|
||||
|
||||
binary-extensions@2.3.0:
|
||||
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -3240,6 +3294,10 @@ packages:
|
||||
resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
cookies@0.9.1:
|
||||
resolution: {integrity: sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
core-util-is@1.0.3:
|
||||
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
|
||||
|
||||
@@ -3323,6 +3381,9 @@ packages:
|
||||
babel-plugin-macros:
|
||||
optional: true
|
||||
|
||||
deep-equal@1.0.1:
|
||||
resolution: {integrity: sha512-bHtC0iYvWhyaTzvV3CZgPeZQqCOBGyGsVV7v4eevpdkLHfiSrXUdBG+qAuSz4RI70sszvjQ1QSZ98An1yNwpSw==}
|
||||
|
||||
deep-is@0.1.4:
|
||||
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
|
||||
|
||||
@@ -3359,6 +3420,10 @@ packages:
|
||||
resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==}
|
||||
engines: {node: '>=0.10'}
|
||||
|
||||
depd@1.1.2:
|
||||
resolution: {integrity: sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
depd@2.0.0:
|
||||
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -3469,6 +3534,9 @@ packages:
|
||||
resolution: {integrity: sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
|
||||
error-causes@3.0.2:
|
||||
resolution: {integrity: sha512-i0B8zq1dHL6mM85FGoxaJnVtx6LD5nL2v0hlpGdntg5FOSyzQ46c9lmz5qx0xRS2+PWHGOHcYxGIBC5Le2dRMw==}
|
||||
|
||||
error-ex@1.3.4:
|
||||
resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==}
|
||||
|
||||
@@ -3666,6 +3734,10 @@ packages:
|
||||
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
eta@4.5.0:
|
||||
resolution: {integrity: sha512-qifAYjuW5AM1eEEIsFnOwB+TGqu6ynU3OKj9WbUTOtUBHFPZqL03XUW34kbp3zm19Ald+U8dEyRXaVsUck+Y1g==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
etag@1.8.1:
|
||||
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
@@ -4032,10 +4104,22 @@ packages:
|
||||
html-escaper@2.0.2:
|
||||
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
|
||||
|
||||
http-assert@1.5.0:
|
||||
resolution: {integrity: sha512-uPpH7OKX4H25hBmU6G1jWNaqJGpTXxey+YOUizJUAgu0AjLUeC8D73hTrhvDS5D+GJN1DN1+hhc/eF/wpxtp0w==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
http-errors@1.8.1:
|
||||
resolution: {integrity: sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
http-errors@2.0.0:
|
||||
resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
http-errors@2.0.1:
|
||||
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
https-proxy-agent@5.0.1:
|
||||
resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
|
||||
engines: {node: '>= 6'}
|
||||
@@ -4458,6 +4542,9 @@ packages:
|
||||
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
|
||||
hasBin: true
|
||||
|
||||
jose@6.1.3:
|
||||
resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==}
|
||||
|
||||
joycon@3.1.1:
|
||||
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -4539,6 +4626,11 @@ packages:
|
||||
jws@4.0.1:
|
||||
resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==}
|
||||
|
||||
keygrip@1.1.0:
|
||||
resolution: {integrity: sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==}
|
||||
engines: {node: '>= 0.6'}
|
||||
deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
|
||||
|
||||
keyv@4.5.4:
|
||||
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
||||
|
||||
@@ -4546,6 +4638,13 @@ packages:
|
||||
resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
koa-compose@4.1.0:
|
||||
resolution: {integrity: sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw==}
|
||||
|
||||
koa@3.1.1:
|
||||
resolution: {integrity: sha512-KDDuvpfqSK0ZKEO2gCPedNjl5wYpfj+HNiuVRlbhd1A88S3M0ySkdf2V/EJ4NWt5dwh5PXCdcenrKK2IQJAxsg==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
language-subtag-registry@0.3.23:
|
||||
resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==}
|
||||
|
||||
@@ -4752,6 +4851,10 @@ packages:
|
||||
resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
media-typer@1.1.0:
|
||||
resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
memfs@3.5.3:
|
||||
resolution: {integrity: sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==}
|
||||
engines: {node: '>= 4.0.0'}
|
||||
@@ -4778,10 +4881,18 @@ packages:
|
||||
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
mime-db@1.54.0:
|
||||
resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
mime-types@2.1.35:
|
||||
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
mime-types@3.0.2:
|
||||
resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
mime@1.6.0:
|
||||
resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -5008,6 +5119,9 @@ packages:
|
||||
ohash@2.0.11:
|
||||
resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==}
|
||||
|
||||
oidc-provider@9.6.0:
|
||||
resolution: {integrity: sha512-CCRUYPOumEy/DT+L86H40WgXjXfDHlsJYZdyd4ZKGFxJh/kAd7DxMX3dwpbX0g+WjB+NWU+kla1b/yZmHNcR0Q==}
|
||||
|
||||
on-finished@2.4.1:
|
||||
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -5126,6 +5240,9 @@ packages:
|
||||
path-to-regexp@3.3.0:
|
||||
resolution: {integrity: sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==}
|
||||
|
||||
path-to-regexp@8.3.0:
|
||||
resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==}
|
||||
|
||||
path-type@4.0.0:
|
||||
resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -5265,6 +5382,10 @@ packages:
|
||||
queue-microtask@1.2.3:
|
||||
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
||||
|
||||
quick-lru@7.3.0:
|
||||
resolution: {integrity: sha512-k9lSsjl36EJdK7I06v7APZCbyGT2vMTsYSRX1Q2nbYmnkBqgUhRkAuzH08Ciotteu/PLJmIF2+tti7o3C/ts2g==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
randombytes@2.1.0:
|
||||
resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==}
|
||||
|
||||
@@ -5276,6 +5397,10 @@ packages:
|
||||
resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
raw-body@3.0.2:
|
||||
resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==}
|
||||
engines: {node: '>= 0.10'}
|
||||
|
||||
rc9@2.1.2:
|
||||
resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==}
|
||||
|
||||
@@ -5602,10 +5727,18 @@ packages:
|
||||
standard-as-callback@2.1.0:
|
||||
resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==}
|
||||
|
||||
statuses@1.5.0:
|
||||
resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
statuses@2.0.1:
|
||||
resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
statuses@2.0.2:
|
||||
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
stop-iteration-iterator@1.1.0:
|
||||
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -5903,6 +6036,10 @@ packages:
|
||||
tslib@2.8.1:
|
||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||
|
||||
tsscmp@1.0.6:
|
||||
resolution: {integrity: sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==}
|
||||
engines: {node: '>=0.6.x'}
|
||||
|
||||
tsup@8.5.1:
|
||||
resolution: {integrity: sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -5981,6 +6118,10 @@ packages:
|
||||
resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
type-is@2.0.1:
|
||||
resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
typed-array-buffer@1.0.3:
|
||||
resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -7405,6 +7546,20 @@ snapshots:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
'@koa/cors@5.0.0':
|
||||
dependencies:
|
||||
vary: 1.1.2
|
||||
|
||||
'@koa/router@15.2.0(koa@3.1.1)':
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
http-errors: 2.0.1
|
||||
koa: 3.1.1
|
||||
koa-compose: 4.1.0
|
||||
path-to-regexp: 8.3.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@ljharb/through@2.3.14':
|
||||
dependencies:
|
||||
call-bind: 1.0.8
|
||||
@@ -7605,6 +7760,8 @@ snapshots:
|
||||
'@next/swc-win32-x64-msvc@16.1.1':
|
||||
optional: true
|
||||
|
||||
'@noble/hashes@2.0.1': {}
|
||||
|
||||
'@nodelib/fs.scandir@2.1.5':
|
||||
dependencies:
|
||||
'@nodelib/fs.stat': 2.0.5
|
||||
@@ -7627,6 +7784,12 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
|
||||
'@paralleldrive/cuid2@3.0.6':
|
||||
dependencies:
|
||||
'@noble/hashes': 2.0.1
|
||||
bignumber.js: 9.3.1
|
||||
error-causes: 3.0.2
|
||||
|
||||
'@pkgjs/parseargs@0.11.0':
|
||||
optional: true
|
||||
|
||||
@@ -8649,6 +8812,10 @@ snapshots:
|
||||
tslib: 2.8.1
|
||||
optional: true
|
||||
|
||||
'@types/accepts@1.3.7':
|
||||
dependencies:
|
||||
'@types/node': 22.19.3
|
||||
|
||||
'@types/babel__core@7.20.5':
|
||||
dependencies:
|
||||
'@babel/parser': 7.28.5
|
||||
@@ -8683,6 +8850,15 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/node': 22.19.3
|
||||
|
||||
'@types/content-disposition@0.5.9': {}
|
||||
|
||||
'@types/cookies@0.9.2':
|
||||
dependencies:
|
||||
'@types/connect': 3.4.38
|
||||
'@types/express': 5.0.6
|
||||
'@types/keygrip': 1.0.6
|
||||
'@types/node': 22.19.3
|
||||
|
||||
'@types/eslint-scope@3.7.7':
|
||||
dependencies:
|
||||
'@types/eslint': 9.6.1
|
||||
@@ -8714,6 +8890,8 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/node': 22.19.3
|
||||
|
||||
'@types/http-assert@1.5.6': {}
|
||||
|
||||
'@types/http-errors@2.0.5': {}
|
||||
|
||||
'@types/inquirer@9.0.9':
|
||||
@@ -8749,6 +8927,23 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/node': 22.19.3
|
||||
|
||||
'@types/keygrip@1.0.6': {}
|
||||
|
||||
'@types/koa-compose@3.2.9':
|
||||
dependencies:
|
||||
'@types/koa': 3.0.1
|
||||
|
||||
'@types/koa@3.0.1':
|
||||
dependencies:
|
||||
'@types/accepts': 1.3.7
|
||||
'@types/content-disposition': 0.5.9
|
||||
'@types/cookies': 0.9.2
|
||||
'@types/http-assert': 1.5.6
|
||||
'@types/http-errors': 2.0.5
|
||||
'@types/keygrip': 1.0.6
|
||||
'@types/koa-compose': 3.2.9
|
||||
'@types/node': 22.19.3
|
||||
|
||||
'@types/liftoff@4.0.3':
|
||||
dependencies:
|
||||
'@types/fined': 1.1.5
|
||||
@@ -8777,6 +8972,12 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- aws-crt
|
||||
|
||||
'@types/oidc-provider@9.5.0':
|
||||
dependencies:
|
||||
'@types/keygrip': 1.0.6
|
||||
'@types/koa': 3.0.1
|
||||
'@types/node': 22.19.3
|
||||
|
||||
'@types/passport-jwt@4.0.1':
|
||||
dependencies:
|
||||
'@types/jsonwebtoken': 9.0.10
|
||||
@@ -9353,6 +9554,8 @@ snapshots:
|
||||
- encoding
|
||||
- supports-color
|
||||
|
||||
bignumber.js@9.3.1: {}
|
||||
|
||||
binary-extensions@2.3.0: {}
|
||||
|
||||
bl@4.1.0:
|
||||
@@ -9623,6 +9826,11 @@ snapshots:
|
||||
|
||||
cookie@0.7.1: {}
|
||||
|
||||
cookies@0.9.1:
|
||||
dependencies:
|
||||
depd: 2.0.0
|
||||
keygrip: 1.1.0
|
||||
|
||||
core-util-is@1.0.3: {}
|
||||
|
||||
cors@2.8.5:
|
||||
@@ -9700,6 +9908,8 @@ snapshots:
|
||||
|
||||
dedent@1.7.1: {}
|
||||
|
||||
deep-equal@1.0.1: {}
|
||||
|
||||
deep-is@0.1.4: {}
|
||||
|
||||
deepmerge-ts@7.1.5: {}
|
||||
@@ -9730,6 +9940,8 @@ snapshots:
|
||||
|
||||
denque@2.1.0: {}
|
||||
|
||||
depd@1.1.2: {}
|
||||
|
||||
depd@2.0.0: {}
|
||||
|
||||
destr@2.0.5: {}
|
||||
@@ -9811,6 +10023,8 @@ snapshots:
|
||||
graceful-fs: 4.2.11
|
||||
tapable: 2.3.0
|
||||
|
||||
error-causes@3.0.2: {}
|
||||
|
||||
error-ex@1.3.4:
|
||||
dependencies:
|
||||
is-arrayish: 0.2.1
|
||||
@@ -10209,6 +10423,8 @@ snapshots:
|
||||
|
||||
esutils@2.0.3: {}
|
||||
|
||||
eta@4.5.0: {}
|
||||
|
||||
etag@1.8.1: {}
|
||||
|
||||
eventemitter3@5.0.1: {}
|
||||
@@ -10647,6 +10863,19 @@ snapshots:
|
||||
|
||||
html-escaper@2.0.2: {}
|
||||
|
||||
http-assert@1.5.0:
|
||||
dependencies:
|
||||
deep-equal: 1.0.1
|
||||
http-errors: 1.8.1
|
||||
|
||||
http-errors@1.8.1:
|
||||
dependencies:
|
||||
depd: 1.1.2
|
||||
inherits: 2.0.4
|
||||
setprototypeof: 1.2.0
|
||||
statuses: 1.5.0
|
||||
toidentifier: 1.0.1
|
||||
|
||||
http-errors@2.0.0:
|
||||
dependencies:
|
||||
depd: 2.0.0
|
||||
@@ -10655,6 +10884,14 @@ snapshots:
|
||||
statuses: 2.0.1
|
||||
toidentifier: 1.0.1
|
||||
|
||||
http-errors@2.0.1:
|
||||
dependencies:
|
||||
depd: 2.0.0
|
||||
inherits: 2.0.4
|
||||
setprototypeof: 1.2.0
|
||||
statuses: 2.0.2
|
||||
toidentifier: 1.0.1
|
||||
|
||||
https-proxy-agent@5.0.1:
|
||||
dependencies:
|
||||
agent-base: 6.0.2
|
||||
@@ -11311,6 +11548,8 @@ snapshots:
|
||||
|
||||
jiti@2.6.1: {}
|
||||
|
||||
jose@6.1.3: {}
|
||||
|
||||
joycon@3.1.1: {}
|
||||
|
||||
js-tokens@4.0.0: {}
|
||||
@@ -11411,12 +11650,39 @@ snapshots:
|
||||
jwa: 2.0.1
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
keygrip@1.1.0:
|
||||
dependencies:
|
||||
tsscmp: 1.0.6
|
||||
|
||||
keyv@4.5.4:
|
||||
dependencies:
|
||||
json-buffer: 3.0.1
|
||||
|
||||
kleur@3.0.3: {}
|
||||
|
||||
koa-compose@4.1.0: {}
|
||||
|
||||
koa@3.1.1:
|
||||
dependencies:
|
||||
accepts: 1.3.8
|
||||
content-disposition: 0.5.4
|
||||
content-type: 1.0.5
|
||||
cookies: 0.9.1
|
||||
delegates: 1.0.0
|
||||
destroy: 1.2.0
|
||||
encodeurl: 2.0.0
|
||||
escape-html: 1.0.3
|
||||
fresh: 0.5.2
|
||||
http-assert: 1.5.0
|
||||
http-errors: 2.0.0
|
||||
koa-compose: 4.1.0
|
||||
mime-types: 3.0.2
|
||||
on-finished: 2.4.1
|
||||
parseurl: 1.3.3
|
||||
statuses: 2.0.1
|
||||
type-is: 2.0.1
|
||||
vary: 1.1.2
|
||||
|
||||
language-subtag-registry@0.3.23: {}
|
||||
|
||||
language-tags@1.0.9:
|
||||
@@ -11580,6 +11846,8 @@ snapshots:
|
||||
|
||||
media-typer@0.3.0: {}
|
||||
|
||||
media-typer@1.1.0: {}
|
||||
|
||||
memfs@3.5.3:
|
||||
dependencies:
|
||||
fs-monkey: 1.1.0
|
||||
@@ -11599,10 +11867,16 @@ snapshots:
|
||||
|
||||
mime-db@1.52.0: {}
|
||||
|
||||
mime-db@1.54.0: {}
|
||||
|
||||
mime-types@2.1.35:
|
||||
dependencies:
|
||||
mime-db: 1.52.0
|
||||
|
||||
mime-types@3.0.2:
|
||||
dependencies:
|
||||
mime-db: 1.54.0
|
||||
|
||||
mime@1.6.0: {}
|
||||
|
||||
mimic-fn@2.1.0: {}
|
||||
@@ -11844,6 +12118,21 @@ snapshots:
|
||||
|
||||
ohash@2.0.11: {}
|
||||
|
||||
oidc-provider@9.6.0:
|
||||
dependencies:
|
||||
'@koa/cors': 5.0.0
|
||||
'@koa/router': 15.2.0(koa@3.1.1)
|
||||
debug: 4.4.3
|
||||
eta: 4.5.0
|
||||
jose: 6.1.3
|
||||
jsesc: 3.1.0
|
||||
koa: 3.1.1
|
||||
nanoid: 5.1.6
|
||||
quick-lru: 7.3.0
|
||||
raw-body: 3.0.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
on-finished@2.4.1:
|
||||
dependencies:
|
||||
ee-first: 1.1.1
|
||||
@@ -11966,6 +12255,8 @@ snapshots:
|
||||
|
||||
path-to-regexp@3.3.0: {}
|
||||
|
||||
path-to-regexp@8.3.0: {}
|
||||
|
||||
path-type@4.0.0: {}
|
||||
|
||||
pathe@2.0.3: {}
|
||||
@@ -12093,6 +12384,8 @@ snapshots:
|
||||
|
||||
queue-microtask@1.2.3: {}
|
||||
|
||||
quick-lru@7.3.0: {}
|
||||
|
||||
randombytes@2.1.0:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
@@ -12106,6 +12399,13 @@ snapshots:
|
||||
iconv-lite: 0.4.24
|
||||
unpipe: 1.0.0
|
||||
|
||||
raw-body@3.0.2:
|
||||
dependencies:
|
||||
bytes: 3.1.2
|
||||
http-errors: 2.0.1
|
||||
iconv-lite: 0.7.2
|
||||
unpipe: 1.0.0
|
||||
|
||||
rc9@2.1.2:
|
||||
dependencies:
|
||||
defu: 6.1.4
|
||||
@@ -12497,8 +12797,12 @@ snapshots:
|
||||
|
||||
standard-as-callback@2.1.0: {}
|
||||
|
||||
statuses@1.5.0: {}
|
||||
|
||||
statuses@2.0.1: {}
|
||||
|
||||
statuses@2.0.2: {}
|
||||
|
||||
stop-iteration-iterator@1.1.0:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
@@ -12808,6 +13112,8 @@ snapshots:
|
||||
|
||||
tslib@2.8.1: {}
|
||||
|
||||
tsscmp@1.0.6: {}
|
||||
|
||||
tsup@8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3):
|
||||
dependencies:
|
||||
bundle-require: 5.1.0(esbuild@0.27.2)
|
||||
@@ -12885,6 +13191,12 @@ snapshots:
|
||||
media-typer: 0.3.0
|
||||
mime-types: 2.1.35
|
||||
|
||||
type-is@2.0.1:
|
||||
dependencies:
|
||||
content-type: 1.0.5
|
||||
media-typer: 1.1.0
|
||||
mime-types: 3.0.2
|
||||
|
||||
typed-array-buffer@1.0.3:
|
||||
dependencies:
|
||||
call-bound: 1.0.4
|
||||
|
||||
Reference in New Issue
Block a user