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:
charilezhou
2026-01-20 17:22:32 +08:00
parent 8db25538d4
commit 90513e8278
38 changed files with 4186 additions and 16 deletions

View File

@@ -28,6 +28,8 @@ JWT_EXPIRES_IN="7d"
PORT=4000 PORT=4000
# 运行环境: development | production | test # 运行环境: development | production | test
NODE_ENV=development NODE_ENV=development
# 前端 URL用于 OIDC 交互重定向)
FRONTEND_URL=http://localhost:3000
# ----- 加密配置 ----- # ----- 加密配置 -----
# 是否启用通信加密 (true/false) # 是否启用通信加密 (true/false)
@@ -68,3 +70,13 @@ MINIO_BUCKET=seclusion
# 如果设置,将使用此 URL 作为文件访问地址前缀 # 如果设置,将使用此 URL 作为文件访问地址前缀
# 示例: https://cdn.example.com 或 https://example.com/storage # 示例: https://cdn.example.com 或 https://example.com/storage
MINIO_PUBLIC_URL= 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 私钥RS256PEM 格式Base64 编码)
# 生成方式: openssl genrsa 2048 | base64 -w 0
# 注意: 生产环境必须配置,开发环境可留空使用临时密钥
OIDC_JWKS_PRIVATE_KEY=

View File

@@ -27,6 +27,7 @@
"@nestjs/passport": "^10.0.3", "@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.4.15", "@nestjs/platform-express": "^10.4.15",
"@nestjs/swagger": "^8.1.0", "@nestjs/swagger": "^8.1.0",
"@paralleldrive/cuid2": "^3.0.6",
"@prisma/client": "^6.1.0", "@prisma/client": "^6.1.0",
"@seclusion/shared": "workspace:*", "@seclusion/shared": "workspace:*",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
@@ -36,6 +37,7 @@
"minio": "^8.0.6", "minio": "^8.0.6",
"nanoid": "^5.1.6", "nanoid": "^5.1.6",
"nodemailer": "^7.0.12", "nodemailer": "^7.0.12",
"oidc-provider": "^9.6.0",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
@@ -55,6 +57,7 @@
"@types/multer": "^2.0.0", "@types/multer": "^2.0.0",
"@types/node": "^22.10.2", "@types/node": "^22.10.2",
"@types/nodemailer": "^7.0.5", "@types/nodemailer": "^7.0.5",
"@types/oidc-provider": "^9.5.0",
"@types/passport-jwt": "^4.0.1", "@types/passport-jwt": "^4.0.1",
"@types/uuid": "^11.0.0", "@types/uuid": "^11.0.0",
"dotenv-cli": "^11.0.0", "dotenv-cli": "^11.0.0",

View File

@@ -20,6 +20,7 @@ model User {
roles UserRole[] roles UserRole[]
uploadFiles File[] @relation("FileUploader") uploadFiles File[] @relation("FileUploader")
oidcGrants OidcGrant[]
// 复合唯一约束:未删除用户邮箱唯一,已删除用户邮箱可重复 // 复合唯一约束:未删除用户邮箱唯一,已删除用户邮箱可重复
@@unique([email, deletedAt]) @@unique([email, deletedAt])
@@ -229,3 +230,73 @@ model ClassTeacher {
@@unique([classId, teacherId]) @@unique([classId, teacherId])
@@map("class_teachers") @@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")
}

View File

@@ -43,6 +43,11 @@ const permissions = [
{ code: 'student:read', name: '查看学生', resource: 'student', action: 'read' }, { code: 'student:read', name: '查看学生', resource: 'student', action: 'read' },
{ code: 'student:update', name: '更新学生', resource: 'student', action: 'update' }, { code: 'student:update', name: '更新学生', resource: 'student', action: 'update' },
{ code: 'student:delete', name: '删除学生', resource: 'student', action: 'delete' }, { 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, sort: 4,
isStatic: true, isStatic: true,
}, },
{
code: 'oidc-client-management',
name: 'OIDC 客户端',
type: 'menu',
path: '/oidc-clients',
icon: 'KeyRound',
sort: 5,
isStatic: true,
},
{ {
code: 'profile', code: 'profile',
name: '个人中心', name: '个人中心',
@@ -198,6 +212,7 @@ const systemSubMenuCodes = [
'role-management', 'role-management',
'permission-management', 'permission-management',
'menu-management', 'menu-management',
'oidc-client-management',
]; ];
// 教学管理子菜单 codes // 教学管理子菜单 codes

View File

@@ -12,6 +12,7 @@ import { MailModule } from './common/mail/mail.module';
import { RedisModule } from './common/redis/redis.module'; import { RedisModule } from './common/redis/redis.module';
import { StorageModule } from './common/storage/storage.module'; import { StorageModule } from './common/storage/storage.module';
import { FileModule } from './file/file.module'; import { FileModule } from './file/file.module';
import { OidcModule } from './oidc/oidc.module';
import { PermissionModule } from './permission/permission.module'; import { PermissionModule } from './permission/permission.module';
import { PrismaModule } from './prisma/prisma.module'; import { PrismaModule } from './prisma/prisma.module';
import { StudentModule } from './student/student.module'; import { StudentModule } from './student/student.module';
@@ -36,6 +37,7 @@ import { UserModule } from './user/user.module';
AuthModule, AuthModule,
UserModule, UserModule,
PermissionModule, PermissionModule,
OidcModule,
// 教学管理模块 // 教学管理模块
TeacherModule, TeacherModule,
ClassModule, ClassModule,

View File

@@ -1,16 +1,20 @@
import { ValidationPipe } from '@nestjs/common'; import { Logger, ValidationPipe } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import type { NestExpressApplication } from '@nestjs/platform-express';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
import { EncryptionInterceptor } from './common/crypto/encryption.interceptor'; import { EncryptionInterceptor } from './common/crypto/encryption.interceptor';
import { OidcService } from './oidc/oidc.service';
// eslint-disable-next-line @typescript-eslint/no-require-imports // eslint-disable-next-line @typescript-eslint/no-require-imports
const { version } = require('../package.json'); const { version } = require('../package.json');
const logger = new Logger('Bootstrap');
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create(AppModule, { const app = await NestFactory.create<NestExpressApplication>(AppModule, {
// 日志级别: 'log' | 'error' | 'warn' | 'debug' | 'verbose' // 日志级别: 'log' | 'error' | 'warn' | 'debug' | 'verbose'
// 开发环境显示所有日志,生产环境只显示 error/warn/log // 开发环境显示所有日志,生产环境只显示 error/warn/log
logger: logger:
@@ -23,6 +27,19 @@ async function bootstrap() {
const port = configService.get<number>('PORT', 4000); const port = configService.get<number>('PORT', 4000);
const enableEncryption = configService.get<string>('ENABLE_ENCRYPTION') === 'true'; 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( app.useGlobalPipes(
new ValidationPipe({ new ValidationPipe({
@@ -39,8 +56,9 @@ async function bootstrap() {
} }
// CORS 配置 // CORS 配置
const frontendUrl = configService.get<string>('FRONTEND_URL', 'http://localhost:3000');
app.enableCors({ app.enableCors({
origin: ['http://localhost:3000'], origin: [frontendUrl],
credentials: true, credentials: true,
exposedHeaders: ['X-Encrypted'], exposedHeaders: ['X-Encrypted'],
}); });
@@ -56,9 +74,10 @@ async function bootstrap() {
SwaggerModule.setup('api/docs', app, document); SwaggerModule.setup('api/docs', app, document);
await app.listen(port); await app.listen(port);
console.log(`🚀 Application is running on: http://localhost:${port}`); logger.log(`Application is running on: http://localhost:${port}`);
console.log(`📚 Swagger docs: http://localhost:${port}/api/docs`); logger.log(`Swagger docs: http://localhost:${port}/api/docs`);
console.log(`🔐 Encryption: ${enableEncryption ? 'enabled' : 'disabled'}`); logger.log(`Encryption: ${enableEncryption ? 'enabled' : 'disabled'}`);
logger.log(`OIDC Discovery: http://localhost:${port}/oidc/.well-known/openid-configuration`);
} }
bootstrap(); bootstrap();

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

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

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

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

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

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

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

View 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 : '中止授权失败',
});
}
}
}

View 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 {}

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

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

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

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

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

View 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">
&copy; {new Date().getFullYear()} {siteConfig.name}. All rights reserved.
</div>
</footer>
</div>
);
}

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

View File

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

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

View 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`);
}

View 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&#10;每行一个地址"
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&#10;每行一个地址"
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>
);
}

View 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&#10;每行一个地址"
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>
);
}

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

View File

@@ -0,0 +1,3 @@
export { OidcClientsTable } from './OidcClientsTable';
export { OidcClientCreateDialog } from './OidcClientCreateDialog';
export { OidcClientEditDialog } from './OidcClientEditDialog';

View File

@@ -21,6 +21,8 @@ export const API_ENDPOINTS = {
TEACHERS: '/teachers', TEACHERS: '/teachers',
CLASSES: '/classes', CLASSES: '/classes',
STUDENTS: '/students', STUDENTS: '/students',
// OIDC 交互(使用 Cookie 而非 JWT 认证)
OIDC_INTERACTION: '/oidc-interaction',
} as const; } as const;
// 分页默认值 // 分页默认值

View 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] });
},
});
}

View File

@@ -1,2 +0,0 @@
export { authService } from './auth.service';
export { userService, type GetUsersParams } from './user.service';

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

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

View File

@@ -506,9 +506,11 @@ export class OidcClientController {
## 四、OIDC 端点说明 ## 四、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/authorize` | GET | 授权端点 |
| `/oidc/token` | POST | 令牌端点 | | `/oidc/token` | POST | 令牌端点 |
| `/oidc/userinfo` | GET/POST | 用户信息端点 | | `/oidc/userinfo` | GET/POST | 用户信息端点 |
@@ -516,9 +518,15 @@ export class OidcClientController {
| `/oidc/revoke` | POST | 令牌撤销 | | `/oidc/revoke` | POST | 令牌撤销 |
| `/oidc/introspect` | POST | 令牌内省 | | `/oidc/introspect` | POST | 令牌内省 |
| `/oidc/logout` | GET/POST | 登出端点 | | `/oidc/logout` | GET/POST | 登出端点 |
| `/oidc/interaction/:uid` | GET | 获取交互详情 |
| `/oidc/interaction/:uid/login` | POST | 提交登录 | 自定义交互 API 端点在 `/oidc-interaction` 路径下(避免与 oidc-provider 冲突):
| `/oidc/interaction/:uid/confirm` | POST | 提交授权确认 |
| 端点 | 方法 | 说明 |
|------|------|------|
| `/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 ```bash
# ----- OIDC Provider 配置 ----- # ----- OIDC Provider 配置 -----
# OIDC 签发者 URL必须是可公开访问的 HTTPS URL # OIDC 签发者 URL必须是可公开访问的 URL包含 /oidc 路径
OIDC_ISSUER=http://localhost:4000 OIDC_ISSUER=http://localhost:4000/oidc
# OIDC Cookie 签名密钥(生产环境必须修改) # OIDC Cookie 签名密钥(生产环境必须修改)
OIDC_COOKIE_SECRET=your-oidc-cookie-secret-change-in-production OIDC_COOKIE_SECRET=your-oidc-cookie-secret-change-in-production
# OIDC JWKS 私钥RS256PEM 格式Base64 编码) # OIDC JWKS 私钥RS256PEM 格式Base64 编码)
@@ -939,10 +947,87 @@ OIDC_JWKS_PRIVATE_KEY=
3. API 文档完善 3. API 文档完善
4. 使用文档 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/prisma/schema.prisma` | 添加 OIDC 相关数据模型 |
| `apps/api/src/oidc/` | OIDC 模块目录 | | `apps/api/src/oidc/` | OIDC 模块目录 |
| `apps/api/src/auth/auth.service.ts` | 参考现有认证逻辑 | | `apps/api/src/auth/auth.service.ts` | 参考现有认证逻辑 |
@@ -950,7 +1035,7 @@ OIDC_JWKS_PRIVATE_KEY=
| `apps/web/src/app/(dashboard)/oidc-clients/` | OIDC 客户端管理页面 | | `apps/web/src/app/(dashboard)/oidc-clients/` | OIDC 客户端管理页面 |
| `packages/shared/src/types/oidc.ts` | OIDC 共享类型定义 | | `packages/shared/src/types/oidc.ts` | OIDC 共享类型定义 |
## 十、参考资料 ## 十、参考资料
- [panva/node-oidc-provider](https://github.com/panva/node-oidc-provider) - [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) - [node-oidc-provider Documentation](https://github.com/panva/node-oidc-provider/blob/main/docs/README.md)

View File

@@ -257,3 +257,24 @@ export {
type UserRoleResponse, type UserRoleResponse,
type UserWithRolesResponse, type UserWithRolesResponse,
} from './permission'; } 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';

View 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
View File

@@ -56,6 +56,9 @@ importers:
'@nestjs/swagger': '@nestjs/swagger':
specifier: ^8.1.0 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) 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': '@prisma/client':
specifier: ^6.1.0 specifier: ^6.1.0
version: 6.19.1(prisma@6.19.1(typescript@5.9.3))(typescript@5.9.3) version: 6.19.1(prisma@6.19.1(typescript@5.9.3))(typescript@5.9.3)
@@ -83,6 +86,9 @@ importers:
nodemailer: nodemailer:
specifier: ^7.0.12 specifier: ^7.0.12
version: 7.0.12 version: 7.0.12
oidc-provider:
specifier: ^9.6.0
version: 9.6.0
passport: passport:
specifier: ^0.7.0 specifier: ^0.7.0
version: 0.7.0 version: 0.7.0
@@ -135,6 +141,9 @@ importers:
'@types/nodemailer': '@types/nodemailer':
specifier: ^7.0.5 specifier: ^7.0.5
version: 7.0.5 version: 7.0.5
'@types/oidc-provider':
specifier: ^9.5.0
version: 9.5.0
'@types/passport-jwt': '@types/passport-jwt':
specifier: ^4.0.1 specifier: ^4.0.1
version: 4.0.1 version: 4.0.1
@@ -1184,6 +1193,16 @@ packages:
'@jridgewell/trace-mapping@0.3.9': '@jridgewell/trace-mapping@0.3.9':
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} 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': '@ljharb/through@2.3.14':
resolution: {integrity: sha512-ajBvlKpWucBB17FuQYUShqpqy8GRgYEpJW0vWJbUu1CV9lWyrDCapy0lScU8T8Z6qn49sSwJB3+M+evYIdGg+A==} resolution: {integrity: sha512-ajBvlKpWucBB17FuQYUShqpqy8GRgYEpJW0vWJbUu1CV9lWyrDCapy0lScU8T8Z6qn49sSwJB3+M+evYIdGg+A==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -1370,6 +1389,10 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] 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': '@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@@ -1391,6 +1414,10 @@ packages:
engines: {node: '>=8.0.0', npm: '>=5.0.0'} engines: {node: '>=8.0.0', npm: '>=5.0.0'}
hasBin: true hasBin: true
'@paralleldrive/cuid2@3.0.6':
resolution: {integrity: sha512-ujtxTTvr4fwPrzuQT7o6VLKs5BzdWetR9+/zRQ0SyK9hVIwZQllEccxgcHYXN6I3Z429y1yg3F6+uiVxMDPrLQ==}
hasBin: true
'@pkgjs/parseargs@0.11.0': '@pkgjs/parseargs@0.11.0':
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'} engines: {node: '>=14'}
@@ -2373,6 +2400,9 @@ packages:
'@tybys/wasm-util@0.10.1': '@tybys/wasm-util@0.10.1':
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
'@types/accepts@1.3.7':
resolution: {integrity: sha512-Pay9fq2lM2wXPWbteBsRAGiWH2hig4ZE2asK+mm7kUzlxRTfL961rj89I6zV/E3PcIkDqyuBEcMxFT7rccugeQ==}
'@types/babel__core@7.20.5': '@types/babel__core@7.20.5':
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
@@ -2394,6 +2424,12 @@ packages:
'@types/connect@3.4.38': '@types/connect@3.4.38':
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} 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': '@types/eslint-scope@3.7.7':
resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==}
@@ -2415,6 +2451,9 @@ packages:
'@types/graceful-fs@4.1.9': '@types/graceful-fs@4.1.9':
resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==} resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==}
'@types/http-assert@1.5.6':
resolution: {integrity: sha512-TTEwmtjgVbYAzZYWyeHPrrtWnfVkm8tQkP8P21uQifPgMRgjrow3XDEYqucuC8SKZJT7pUnhU/JymvjggxO9vw==}
'@types/http-errors@2.0.5': '@types/http-errors@2.0.5':
resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==}
@@ -2445,6 +2484,15 @@ packages:
'@types/jsonwebtoken@9.0.5': '@types/jsonwebtoken@9.0.5':
resolution: {integrity: sha512-VRLSGzik+Unrup6BsouBeHsf4d1hOEgYWTm/7Nmw1sXoN1+tRly/Gy/po3yeahnP4jfnQWWAhQAqcNfH7ngOkA==} 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': '@types/liftoff@4.0.3':
resolution: {integrity: sha512-UgbL2kR5pLrWICvr8+fuSg0u43LY250q7ZMkC+XKC3E+rs/YBDEnQIzsnhU5dYsLlwMi3R75UvCL87pObP1sxw==} resolution: {integrity: sha512-UgbL2kR5pLrWICvr8+fuSg0u43LY250q7ZMkC+XKC3E+rs/YBDEnQIzsnhU5dYsLlwMi3R75UvCL87pObP1sxw==}
@@ -2466,6 +2514,9 @@ packages:
'@types/nodemailer@7.0.5': '@types/nodemailer@7.0.5':
resolution: {integrity: sha512-7WtR4MFJUNN2UFy0NIowBRJswj5KXjXDhlZY43Hmots5eGu5q/dTeFd/I6GgJA/qj3RqO6dDy4SvfcV3fOVeIA==} 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': '@types/passport-jwt@4.0.1':
resolution: {integrity: sha512-Y0Ykz6nWP4jpxgEUYq8NoVZeCQPo1ZndJLfapI249g1jHChvRfZRO/LS3tqu26YgAS/laI1qx98sYGz0IalRXQ==} resolution: {integrity: sha512-Y0Ykz6nWP4jpxgEUYq8NoVZeCQPo1ZndJLfapI249g1jHChvRfZRO/LS3tqu26YgAS/laI1qx98sYGz0IalRXQ==}
@@ -2963,6 +3014,9 @@ packages:
resolution: {integrity: sha512-AGBHOG5hPYZ5Xl9KXzU5iKq9516yEmvCKDg3ecP5kX2aB6UqTeXZxk2ELnDgDm6BQSMlLt9rDB4LoSMx0rYwww==} resolution: {integrity: sha512-AGBHOG5hPYZ5Xl9KXzU5iKq9516yEmvCKDg3ecP5kX2aB6UqTeXZxk2ELnDgDm6BQSMlLt9rDB4LoSMx0rYwww==}
engines: {node: '>= 10.0.0'} engines: {node: '>= 10.0.0'}
bignumber.js@9.3.1:
resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==}
binary-extensions@2.3.0: binary-extensions@2.3.0:
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -3240,6 +3294,10 @@ packages:
resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==} resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==}
engines: {node: '>= 0.6'} 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: core-util-is@1.0.3:
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
@@ -3323,6 +3381,9 @@ packages:
babel-plugin-macros: babel-plugin-macros:
optional: true optional: true
deep-equal@1.0.1:
resolution: {integrity: sha512-bHtC0iYvWhyaTzvV3CZgPeZQqCOBGyGsVV7v4eevpdkLHfiSrXUdBG+qAuSz4RI70sszvjQ1QSZ98An1yNwpSw==}
deep-is@0.1.4: deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
@@ -3359,6 +3420,10 @@ packages:
resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==}
engines: {node: '>=0.10'} engines: {node: '>=0.10'}
depd@1.1.2:
resolution: {integrity: sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==}
engines: {node: '>= 0.6'}
depd@2.0.0: depd@2.0.0:
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@@ -3469,6 +3534,9 @@ packages:
resolution: {integrity: sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==} resolution: {integrity: sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==}
engines: {node: '>=10.13.0'} engines: {node: '>=10.13.0'}
error-causes@3.0.2:
resolution: {integrity: sha512-i0B8zq1dHL6mM85FGoxaJnVtx6LD5nL2v0hlpGdntg5FOSyzQ46c9lmz5qx0xRS2+PWHGOHcYxGIBC5Le2dRMw==}
error-ex@1.3.4: error-ex@1.3.4:
resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==}
@@ -3666,6 +3734,10 @@ packages:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
eta@4.5.0:
resolution: {integrity: sha512-qifAYjuW5AM1eEEIsFnOwB+TGqu6ynU3OKj9WbUTOtUBHFPZqL03XUW34kbp3zm19Ald+U8dEyRXaVsUck+Y1g==}
engines: {node: '>=20'}
etag@1.8.1: etag@1.8.1:
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
@@ -4032,10 +4104,22 @@ packages:
html-escaper@2.0.2: html-escaper@2.0.2:
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} 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: http-errors@2.0.0:
resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==}
engines: {node: '>= 0.8'} 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: https-proxy-agent@5.0.1:
resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
@@ -4458,6 +4542,9 @@ packages:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true hasBin: true
jose@6.1.3:
resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==}
joycon@3.1.1: joycon@3.1.1:
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -4539,6 +4626,11 @@ packages:
jws@4.0.1: jws@4.0.1:
resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} 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: keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
@@ -4546,6 +4638,13 @@ packages:
resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==}
engines: {node: '>=6'} 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: language-subtag-registry@0.3.23:
resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==} resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==}
@@ -4752,6 +4851,10 @@ packages:
resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
media-typer@1.1.0:
resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==}
engines: {node: '>= 0.8'}
memfs@3.5.3: memfs@3.5.3:
resolution: {integrity: sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==} resolution: {integrity: sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==}
engines: {node: '>= 4.0.0'} engines: {node: '>= 4.0.0'}
@@ -4778,10 +4881,18 @@ packages:
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
engines: {node: '>= 0.6'} 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: mime-types@2.1.35:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
mime-types@3.0.2:
resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==}
engines: {node: '>=18'}
mime@1.6.0: mime@1.6.0:
resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==}
engines: {node: '>=4'} engines: {node: '>=4'}
@@ -5008,6 +5119,9 @@ packages:
ohash@2.0.11: ohash@2.0.11:
resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} 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: on-finished@2.4.1:
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@@ -5126,6 +5240,9 @@ packages:
path-to-regexp@3.3.0: path-to-regexp@3.3.0:
resolution: {integrity: sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==} resolution: {integrity: sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==}
path-to-regexp@8.3.0:
resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==}
path-type@4.0.0: path-type@4.0.0:
resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -5265,6 +5382,10 @@ packages:
queue-microtask@1.2.3: queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
quick-lru@7.3.0:
resolution: {integrity: sha512-k9lSsjl36EJdK7I06v7APZCbyGT2vMTsYSRX1Q2nbYmnkBqgUhRkAuzH08Ciotteu/PLJmIF2+tti7o3C/ts2g==}
engines: {node: '>=18'}
randombytes@2.1.0: randombytes@2.1.0:
resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==}
@@ -5276,6 +5397,10 @@ packages:
resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==} resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
raw-body@3.0.2:
resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==}
engines: {node: '>= 0.10'}
rc9@2.1.2: rc9@2.1.2:
resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==} resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==}
@@ -5602,10 +5727,18 @@ packages:
standard-as-callback@2.1.0: standard-as-callback@2.1.0:
resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==}
statuses@1.5.0:
resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==}
engines: {node: '>= 0.6'}
statuses@2.0.1: statuses@2.0.1:
resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
statuses@2.0.2:
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
engines: {node: '>= 0.8'}
stop-iteration-iterator@1.1.0: stop-iteration-iterator@1.1.0:
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -5903,6 +6036,10 @@ packages:
tslib@2.8.1: tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} 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: tsup@8.5.1:
resolution: {integrity: sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==} resolution: {integrity: sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -5981,6 +6118,10 @@ packages:
resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==}
engines: {node: '>= 0.6'} 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: typed-array-buffer@1.0.3:
resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -7405,6 +7546,20 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2 '@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5 '@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': '@ljharb/through@2.3.14':
dependencies: dependencies:
call-bind: 1.0.8 call-bind: 1.0.8
@@ -7605,6 +7760,8 @@ snapshots:
'@next/swc-win32-x64-msvc@16.1.1': '@next/swc-win32-x64-msvc@16.1.1':
optional: true optional: true
'@noble/hashes@2.0.1': {}
'@nodelib/fs.scandir@2.1.5': '@nodelib/fs.scandir@2.1.5':
dependencies: dependencies:
'@nodelib/fs.stat': 2.0.5 '@nodelib/fs.stat': 2.0.5
@@ -7627,6 +7784,12 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- encoding - 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': '@pkgjs/parseargs@0.11.0':
optional: true optional: true
@@ -8649,6 +8812,10 @@ snapshots:
tslib: 2.8.1 tslib: 2.8.1
optional: true optional: true
'@types/accepts@1.3.7':
dependencies:
'@types/node': 22.19.3
'@types/babel__core@7.20.5': '@types/babel__core@7.20.5':
dependencies: dependencies:
'@babel/parser': 7.28.5 '@babel/parser': 7.28.5
@@ -8683,6 +8850,15 @@ snapshots:
dependencies: dependencies:
'@types/node': 22.19.3 '@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': '@types/eslint-scope@3.7.7':
dependencies: dependencies:
'@types/eslint': 9.6.1 '@types/eslint': 9.6.1
@@ -8714,6 +8890,8 @@ snapshots:
dependencies: dependencies:
'@types/node': 22.19.3 '@types/node': 22.19.3
'@types/http-assert@1.5.6': {}
'@types/http-errors@2.0.5': {} '@types/http-errors@2.0.5': {}
'@types/inquirer@9.0.9': '@types/inquirer@9.0.9':
@@ -8749,6 +8927,23 @@ snapshots:
dependencies: dependencies:
'@types/node': 22.19.3 '@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': '@types/liftoff@4.0.3':
dependencies: dependencies:
'@types/fined': 1.1.5 '@types/fined': 1.1.5
@@ -8777,6 +8972,12 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- aws-crt - 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': '@types/passport-jwt@4.0.1':
dependencies: dependencies:
'@types/jsonwebtoken': 9.0.10 '@types/jsonwebtoken': 9.0.10
@@ -9353,6 +9554,8 @@ snapshots:
- encoding - encoding
- supports-color - supports-color
bignumber.js@9.3.1: {}
binary-extensions@2.3.0: {} binary-extensions@2.3.0: {}
bl@4.1.0: bl@4.1.0:
@@ -9623,6 +9826,11 @@ snapshots:
cookie@0.7.1: {} cookie@0.7.1: {}
cookies@0.9.1:
dependencies:
depd: 2.0.0
keygrip: 1.1.0
core-util-is@1.0.3: {} core-util-is@1.0.3: {}
cors@2.8.5: cors@2.8.5:
@@ -9700,6 +9908,8 @@ snapshots:
dedent@1.7.1: {} dedent@1.7.1: {}
deep-equal@1.0.1: {}
deep-is@0.1.4: {} deep-is@0.1.4: {}
deepmerge-ts@7.1.5: {} deepmerge-ts@7.1.5: {}
@@ -9730,6 +9940,8 @@ snapshots:
denque@2.1.0: {} denque@2.1.0: {}
depd@1.1.2: {}
depd@2.0.0: {} depd@2.0.0: {}
destr@2.0.5: {} destr@2.0.5: {}
@@ -9811,6 +10023,8 @@ snapshots:
graceful-fs: 4.2.11 graceful-fs: 4.2.11
tapable: 2.3.0 tapable: 2.3.0
error-causes@3.0.2: {}
error-ex@1.3.4: error-ex@1.3.4:
dependencies: dependencies:
is-arrayish: 0.2.1 is-arrayish: 0.2.1
@@ -10209,6 +10423,8 @@ snapshots:
esutils@2.0.3: {} esutils@2.0.3: {}
eta@4.5.0: {}
etag@1.8.1: {} etag@1.8.1: {}
eventemitter3@5.0.1: {} eventemitter3@5.0.1: {}
@@ -10647,6 +10863,19 @@ snapshots:
html-escaper@2.0.2: {} 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: http-errors@2.0.0:
dependencies: dependencies:
depd: 2.0.0 depd: 2.0.0
@@ -10655,6 +10884,14 @@ snapshots:
statuses: 2.0.1 statuses: 2.0.1
toidentifier: 1.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: https-proxy-agent@5.0.1:
dependencies: dependencies:
agent-base: 6.0.2 agent-base: 6.0.2
@@ -11311,6 +11548,8 @@ snapshots:
jiti@2.6.1: {} jiti@2.6.1: {}
jose@6.1.3: {}
joycon@3.1.1: {} joycon@3.1.1: {}
js-tokens@4.0.0: {} js-tokens@4.0.0: {}
@@ -11411,12 +11650,39 @@ snapshots:
jwa: 2.0.1 jwa: 2.0.1
safe-buffer: 5.2.1 safe-buffer: 5.2.1
keygrip@1.1.0:
dependencies:
tsscmp: 1.0.6
keyv@4.5.4: keyv@4.5.4:
dependencies: dependencies:
json-buffer: 3.0.1 json-buffer: 3.0.1
kleur@3.0.3: {} 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-subtag-registry@0.3.23: {}
language-tags@1.0.9: language-tags@1.0.9:
@@ -11580,6 +11846,8 @@ snapshots:
media-typer@0.3.0: {} media-typer@0.3.0: {}
media-typer@1.1.0: {}
memfs@3.5.3: memfs@3.5.3:
dependencies: dependencies:
fs-monkey: 1.1.0 fs-monkey: 1.1.0
@@ -11599,10 +11867,16 @@ snapshots:
mime-db@1.52.0: {} mime-db@1.52.0: {}
mime-db@1.54.0: {}
mime-types@2.1.35: mime-types@2.1.35:
dependencies: dependencies:
mime-db: 1.52.0 mime-db: 1.52.0
mime-types@3.0.2:
dependencies:
mime-db: 1.54.0
mime@1.6.0: {} mime@1.6.0: {}
mimic-fn@2.1.0: {} mimic-fn@2.1.0: {}
@@ -11844,6 +12118,21 @@ snapshots:
ohash@2.0.11: {} 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: on-finished@2.4.1:
dependencies: dependencies:
ee-first: 1.1.1 ee-first: 1.1.1
@@ -11966,6 +12255,8 @@ snapshots:
path-to-regexp@3.3.0: {} path-to-regexp@3.3.0: {}
path-to-regexp@8.3.0: {}
path-type@4.0.0: {} path-type@4.0.0: {}
pathe@2.0.3: {} pathe@2.0.3: {}
@@ -12093,6 +12384,8 @@ snapshots:
queue-microtask@1.2.3: {} queue-microtask@1.2.3: {}
quick-lru@7.3.0: {}
randombytes@2.1.0: randombytes@2.1.0:
dependencies: dependencies:
safe-buffer: 5.2.1 safe-buffer: 5.2.1
@@ -12106,6 +12399,13 @@ snapshots:
iconv-lite: 0.4.24 iconv-lite: 0.4.24
unpipe: 1.0.0 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: rc9@2.1.2:
dependencies: dependencies:
defu: 6.1.4 defu: 6.1.4
@@ -12497,8 +12797,12 @@ snapshots:
standard-as-callback@2.1.0: {} standard-as-callback@2.1.0: {}
statuses@1.5.0: {}
statuses@2.0.1: {} statuses@2.0.1: {}
statuses@2.0.2: {}
stop-iteration-iterator@1.1.0: stop-iteration-iterator@1.1.0:
dependencies: dependencies:
es-errors: 1.3.0 es-errors: 1.3.0
@@ -12808,6 +13112,8 @@ snapshots:
tslib@2.8.1: {} 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): tsup@8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3):
dependencies: dependencies:
bundle-require: 5.1.0(esbuild@0.27.2) bundle-require: 5.1.0(esbuild@0.27.2)
@@ -12885,6 +13191,12 @@ snapshots:
media-typer: 0.3.0 media-typer: 0.3.0
mime-types: 2.1.35 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: typed-array-buffer@1.0.3:
dependencies: dependencies:
call-bound: 1.0.4 call-bound: 1.0.4