From 90513e8278e6b22e6f9a237d26fd765fcea54991 Mon Sep 17 00:00:00 2001 From: charilezhou Date: Tue, 20 Jan 2026 17:22:32 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E5=AE=8C=E6=95=B4?= =?UTF-8?q?=E7=9A=84=20OIDC=20Provider=20=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端:基于 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 --- apps/api/.env.example | 12 + apps/api/package.json | 3 + apps/api/prisma/schema.prisma | 71 +++ apps/api/prisma/seed.ts | 15 + apps/api/src/app.module.ts | 2 + apps/api/src/main.ts | 31 +- apps/api/src/oidc/adapters/index.ts | 41 ++ apps/api/src/oidc/adapters/prisma.adapter.ts | 168 ++++++ apps/api/src/oidc/adapters/redis.adapter.ts | 128 +++++ apps/api/src/oidc/client.controller.ts | 97 ++++ apps/api/src/oidc/config/oidc.config.ts | 124 +++++ apps/api/src/oidc/dto/client.dto.ts | 186 +++++++ apps/api/src/oidc/dto/interaction.dto.ts | 99 ++++ apps/api/src/oidc/oidc.controller.ts | 202 ++++++++ apps/api/src/oidc/oidc.module.ts | 15 + apps/api/src/oidc/oidc.service.ts | 152 ++++++ apps/api/src/oidc/services/account.service.ts | 44 ++ apps/api/src/oidc/services/client.service.ts | 136 +++++ .../src/oidc/services/interaction.service.ts | 159 ++++++ .../src/app/(dashboard)/oidc-clients/page.tsx | 37 ++ apps/web/src/app/(oidc)/layout.tsx | 41 ++ apps/web/src/app/(oidc)/oidc/error/page.tsx | 57 +++ .../interaction/[uid]/OidcConsentForm.tsx | 149 ++++++ .../oidc/interaction/[uid]/OidcLoginForm.tsx | 111 ++++ .../(oidc)/oidc/interaction/[uid]/page.tsx | 76 +++ .../oidc-clients/OidcClientCreateDialog.tsx | 477 +++++++++++++++++ .../oidc-clients/OidcClientEditDialog.tsx | 478 ++++++++++++++++++ .../oidc-clients/OidcClientsTable.tsx | 331 ++++++++++++ apps/web/src/components/oidc-clients/index.ts | 3 + apps/web/src/config/constants.ts | 2 + apps/web/src/hooks/useOidcClients.ts | 88 ++++ apps/web/src/services/index.ts | 2 - apps/web/src/services/oidc-client.service.ts | 46 ++ .../src/services/oidc-interaction.service.ts | 71 +++ docs/backend/oidc-provider.md | 101 +++- packages/shared/src/types/index.ts | 21 + packages/shared/src/types/oidc.ts | 114 +++++ pnpm-lock.yaml | 312 ++++++++++++ 38 files changed, 4186 insertions(+), 16 deletions(-) create mode 100644 apps/api/src/oidc/adapters/index.ts create mode 100644 apps/api/src/oidc/adapters/prisma.adapter.ts create mode 100644 apps/api/src/oidc/adapters/redis.adapter.ts create mode 100644 apps/api/src/oidc/client.controller.ts create mode 100644 apps/api/src/oidc/config/oidc.config.ts create mode 100644 apps/api/src/oidc/dto/client.dto.ts create mode 100644 apps/api/src/oidc/dto/interaction.dto.ts create mode 100644 apps/api/src/oidc/oidc.controller.ts create mode 100644 apps/api/src/oidc/oidc.module.ts create mode 100644 apps/api/src/oidc/oidc.service.ts create mode 100644 apps/api/src/oidc/services/account.service.ts create mode 100644 apps/api/src/oidc/services/client.service.ts create mode 100644 apps/api/src/oidc/services/interaction.service.ts create mode 100644 apps/web/src/app/(dashboard)/oidc-clients/page.tsx create mode 100644 apps/web/src/app/(oidc)/layout.tsx create mode 100644 apps/web/src/app/(oidc)/oidc/error/page.tsx create mode 100644 apps/web/src/app/(oidc)/oidc/interaction/[uid]/OidcConsentForm.tsx create mode 100644 apps/web/src/app/(oidc)/oidc/interaction/[uid]/OidcLoginForm.tsx create mode 100644 apps/web/src/app/(oidc)/oidc/interaction/[uid]/page.tsx create mode 100644 apps/web/src/components/oidc-clients/OidcClientCreateDialog.tsx create mode 100644 apps/web/src/components/oidc-clients/OidcClientEditDialog.tsx create mode 100644 apps/web/src/components/oidc-clients/OidcClientsTable.tsx create mode 100644 apps/web/src/components/oidc-clients/index.ts create mode 100644 apps/web/src/hooks/useOidcClients.ts delete mode 100644 apps/web/src/services/index.ts create mode 100644 apps/web/src/services/oidc-client.service.ts create mode 100644 apps/web/src/services/oidc-interaction.service.ts create mode 100644 packages/shared/src/types/oidc.ts diff --git a/apps/api/.env.example b/apps/api/.env.example index af9cb17..38ecf74 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -28,6 +28,8 @@ JWT_EXPIRES_IN="7d" PORT=4000 # 运行环境: development | production | test NODE_ENV=development +# 前端 URL(用于 OIDC 交互重定向) +FRONTEND_URL=http://localhost:3000 # ----- 加密配置 ----- # 是否启用通信加密 (true/false) @@ -68,3 +70,13 @@ MINIO_BUCKET=seclusion # 如果设置,将使用此 URL 作为文件访问地址前缀 # 示例: https://cdn.example.com 或 https://example.com/storage MINIO_PUBLIC_URL= + +# ----- OIDC Provider 配置 ----- +# OIDC 签发者 URL(必须是可公开访问的 URL,包含 /oidc 路径) +OIDC_ISSUER=http://localhost:4000/oidc +# OIDC Cookie 签名密钥(生产环境必须修改) +OIDC_COOKIE_SECRET=oidc-cookie-secret-change-in-production +# OIDC JWKS 私钥(RS256,PEM 格式,Base64 编码) +# 生成方式: openssl genrsa 2048 | base64 -w 0 +# 注意: 生产环境必须配置,开发环境可留空使用临时密钥 +OIDC_JWKS_PRIVATE_KEY= diff --git a/apps/api/package.json b/apps/api/package.json index 17b0c5b..a313bbe 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -27,6 +27,7 @@ "@nestjs/passport": "^10.0.3", "@nestjs/platform-express": "^10.4.15", "@nestjs/swagger": "^8.1.0", + "@paralleldrive/cuid2": "^3.0.6", "@prisma/client": "^6.1.0", "@seclusion/shared": "workspace:*", "bcrypt": "^5.1.1", @@ -36,6 +37,7 @@ "minio": "^8.0.6", "nanoid": "^5.1.6", "nodemailer": "^7.0.12", + "oidc-provider": "^9.6.0", "passport": "^0.7.0", "passport-jwt": "^4.0.1", "reflect-metadata": "^0.2.2", @@ -55,6 +57,7 @@ "@types/multer": "^2.0.0", "@types/node": "^22.10.2", "@types/nodemailer": "^7.0.5", + "@types/oidc-provider": "^9.5.0", "@types/passport-jwt": "^4.0.1", "@types/uuid": "^11.0.0", "dotenv-cli": "^11.0.0", diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index f0cc890..f18f70a 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -20,6 +20,7 @@ model User { roles UserRole[] uploadFiles File[] @relation("FileUploader") + oidcGrants OidcGrant[] // 复合唯一约束:未删除用户邮箱唯一,已删除用户邮箱可重复 @@unique([email, deletedAt]) @@ -229,3 +230,73 @@ model ClassTeacher { @@unique([classId, teacherId]) @@map("class_teachers") } + +// ============ OIDC Provider 模块 ============ + +// OIDC 客户端应用 +model OidcClient { + id String @id @default(cuid(2)) + clientId String @unique // 客户端 ID + clientSecret String? // 客户端密钥(公开客户端可为空) + clientName String // 客户端名称 + clientUri String? // 客户端主页 + logoUri String? // Logo URL + redirectUris String[] // 回调地址列表 + postLogoutRedirectUris String[] // 登出后回调地址 + grantTypes String[] // 授权类型: authorization_code, refresh_token, client_credentials + responseTypes String[] // 响应类型: code, token, id_token + scopes String[] // 允许的 scope: openid, profile, email, etc. + tokenEndpointAuthMethod String @default("client_secret_basic") // 认证方式 + applicationType String @default("web") // web / native + isEnabled Boolean @default(true) + isFirstParty Boolean @default(false) // 是否为第一方应用(跳过授权确认) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + + grants OidcGrant[] + refreshTokens OidcRefreshToken[] + + @@map("oidc_clients") +} + +// OIDC 刷新令牌(长期,需要持久化) +model OidcRefreshToken { + id String @id @default(cuid(2)) + jti String @unique // JWT ID + grantId String // 关联的授权 ID + clientId String // 客户端 ID + userId String // 用户 ID + scope String // 授权范围 + data Json // 完整令牌数据 + expiresAt DateTime + consumedAt DateTime? + createdAt DateTime @default(now()) + + client OidcClient @relation(fields: [clientId], references: [clientId]) + + @@index([grantId]) + @@index([clientId]) + @@index([userId]) + @@map("oidc_refresh_tokens") +} + +// OIDC 授权记录(用户对客户端的授权) +model OidcGrant { + id String @id @default(cuid(2)) + grantId String @unique // oidc-provider 生成的 grant ID + clientId String // 客户端 ID + userId String // 用户 ID + scope String // 已授权的 scope + data Json // 完整授权数据 + expiresAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + client OidcClient @relation(fields: [clientId], references: [clientId]) + user User @relation(fields: [userId], references: [id]) + + @@unique([clientId, userId]) + @@index([userId]) + @@map("oidc_grants") +} diff --git a/apps/api/prisma/seed.ts b/apps/api/prisma/seed.ts index b778373..3b57a33 100644 --- a/apps/api/prisma/seed.ts +++ b/apps/api/prisma/seed.ts @@ -43,6 +43,11 @@ const permissions = [ { code: 'student:read', name: '查看学生', resource: 'student', action: 'read' }, { code: 'student:update', name: '更新学生', resource: 'student', action: 'update' }, { code: 'student:delete', name: '删除学生', resource: 'student', action: 'delete' }, + // OIDC 客户端管理权限 + { code: 'oidc-client:create', name: '创建 OIDC 客户端', resource: 'oidc-client', action: 'create' }, + { code: 'oidc-client:read', name: '查看 OIDC 客户端', resource: 'oidc-client', action: 'read' }, + { code: 'oidc-client:update', name: '更新 OIDC 客户端', resource: 'oidc-client', action: 'update' }, + { code: 'oidc-client:delete', name: '删除 OIDC 客户端', resource: 'oidc-client', action: 'delete' }, ]; // 初始角色数据 @@ -171,6 +176,15 @@ const menus = [ sort: 4, isStatic: true, }, + { + code: 'oidc-client-management', + name: 'OIDC 客户端', + type: 'menu', + path: '/oidc-clients', + icon: 'KeyRound', + sort: 5, + isStatic: true, + }, { code: 'profile', name: '个人中心', @@ -198,6 +212,7 @@ const systemSubMenuCodes = [ 'role-management', 'permission-management', 'menu-management', + 'oidc-client-management', ]; // 教学管理子菜单 codes diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 749667e..a79bc55 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -12,6 +12,7 @@ import { MailModule } from './common/mail/mail.module'; import { RedisModule } from './common/redis/redis.module'; import { StorageModule } from './common/storage/storage.module'; import { FileModule } from './file/file.module'; +import { OidcModule } from './oidc/oidc.module'; import { PermissionModule } from './permission/permission.module'; import { PrismaModule } from './prisma/prisma.module'; import { StudentModule } from './student/student.module'; @@ -36,6 +37,7 @@ import { UserModule } from './user/user.module'; AuthModule, UserModule, PermissionModule, + OidcModule, // 教学管理模块 TeacherModule, ClassModule, diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 444a3ee..518a1ce 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -1,16 +1,20 @@ -import { ValidationPipe } from '@nestjs/common'; +import { Logger, ValidationPipe } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { NestFactory } from '@nestjs/core'; +import type { NestExpressApplication } from '@nestjs/platform-express'; import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; import { AppModule } from './app.module'; import { EncryptionInterceptor } from './common/crypto/encryption.interceptor'; +import { OidcService } from './oidc/oidc.service'; // eslint-disable-next-line @typescript-eslint/no-require-imports const { version } = require('../package.json'); +const logger = new Logger('Bootstrap'); + async function bootstrap() { - const app = await NestFactory.create(AppModule, { + const app = await NestFactory.create(AppModule, { // 日志级别: 'log' | 'error' | 'warn' | 'debug' | 'verbose' // 开发环境显示所有日志,生产环境只显示 error/warn/log logger: @@ -23,6 +27,19 @@ async function bootstrap() { const port = configService.get('PORT', 4000); const enableEncryption = configService.get('ENABLE_ENCRYPTION') === 'true'; + // 挂载 OIDC Provider(必须在 NestJS 路由注册之前) + const oidcService = app.get(OidcService); + const provider = oidcService.getProvider(); + if (provider) { + // 获取底层 Express 实例 + const expressApp = app.getHttpAdapter().getInstance(); + // 使用 Express 原生方式挂载 oidc-provider 到 /oidc 路径 + expressApp.use('/oidc', provider.callback()); + logger.log('OIDC Provider middleware mounted'); + } else { + logger.warn('OIDC Provider not initialized'); + } + // 全局验证管道 app.useGlobalPipes( new ValidationPipe({ @@ -39,8 +56,9 @@ async function bootstrap() { } // CORS 配置 + const frontendUrl = configService.get('FRONTEND_URL', 'http://localhost:3000'); app.enableCors({ - origin: ['http://localhost:3000'], + origin: [frontendUrl], credentials: true, exposedHeaders: ['X-Encrypted'], }); @@ -56,9 +74,10 @@ async function bootstrap() { SwaggerModule.setup('api/docs', app, document); await app.listen(port); - console.log(`🚀 Application is running on: http://localhost:${port}`); - console.log(`📚 Swagger docs: http://localhost:${port}/api/docs`); - console.log(`🔐 Encryption: ${enableEncryption ? 'enabled' : 'disabled'}`); + logger.log(`Application is running on: http://localhost:${port}`); + logger.log(`Swagger docs: http://localhost:${port}/api/docs`); + logger.log(`Encryption: ${enableEncryption ? 'enabled' : 'disabled'}`); + logger.log(`OIDC Discovery: http://localhost:${port}/oidc/.well-known/openid-configuration`); } bootstrap(); diff --git a/apps/api/src/oidc/adapters/index.ts b/apps/api/src/oidc/adapters/index.ts new file mode 100644 index 0000000..61fb526 --- /dev/null +++ b/apps/api/src/oidc/adapters/index.ts @@ -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); + }; +} diff --git a/apps/api/src/oidc/adapters/prisma.adapter.ts b/apps/api/src/oidc/adapters/prisma.adapter.ts new file mode 100644 index 0000000..fccd271 --- /dev/null +++ b/apps/api/src/oidc/adapters/prisma.adapter.ts @@ -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 { + 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 { + 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 { + // Prisma 适配器不支持 userCode 查找 + return undefined; + } + + async findByUid(_uid: string): Promise { + // Prisma 适配器不支持 uid 查找 + return undefined; + } + + async consume(id: string): Promise { + switch (this.model) { + case 'RefreshToken': + await this.prisma.oidcRefreshToken.update({ + where: { jti: id }, + data: { consumedAt: new Date() }, + }); + break; + } + } + + async destroy(id: string): Promise { + 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 { + switch (this.model) { + case 'RefreshToken': + await this.prisma.oidcRefreshToken.deleteMany({ + where: { grantId }, + }); + break; + } + } +} diff --git a/apps/api/src/oidc/adapters/redis.adapter.ts b/apps/api/src/oidc/adapters/redis.adapter.ts new file mode 100644 index 0000000..388c200 --- /dev/null +++ b/apps/api/src/oidc/adapters/redis.adapter.ts @@ -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 = { + 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 { + 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 { + const data = await this.redis.getJson(this.key(id)); + return data ?? undefined; + } + + async findByUserCode(userCode: string): Promise { + const id = await this.redis.get(this.userCodeKey(userCode)); + if (!id) return undefined; + return this.find(id); + } + + async findByUid(uid: string): Promise { + const id = await this.redis.get(this.uidKey(uid)); + if (!id) return undefined; + return this.find(id); + } + + async consume(id: string): Promise { + 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 { + 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 { + const grantKey = this.grantKey(grantId); + const keys = await this.redis.smembers(grantKey); + if (keys.length > 0) { + await this.redis.del(...keys, grantKey); + } + } +} diff --git a/apps/api/src/oidc/client.controller.ts b/apps/api/src/oidc/client.controller.ts new file mode 100644 index 0000000..1c4a087 --- /dev/null +++ b/apps/api/src/oidc/client.controller.ts @@ -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); + } +} diff --git a/apps/api/src/oidc/config/oidc.config.ts b/apps/api/src/oidc/config/oidc.config.ts new file mode 100644 index 0000000..d72e18d --- /dev/null +++ b/apps/api/src/oidc/config/oidc.config.ts @@ -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 = ` + + + + OIDC Error + + +

OIDC Error

+
${JSON.stringify(out, null, 2)}
+ +`; + }, + }; +} diff --git a/apps/api/src/oidc/dto/client.dto.ts b/apps/api/src/oidc/dto/client.dto.ts new file mode 100644 index 0000000..16001a1 --- /dev/null +++ b/apps/api/src/oidc/dto/client.dto.ts @@ -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; +} diff --git a/apps/api/src/oidc/dto/interaction.dto.ts b/apps/api/src/oidc/dto/interaction.dto.ts new file mode 100644 index 0000000..5f4a6fe --- /dev/null +++ b/apps/api/src/oidc/dto/interaction.dto.ts @@ -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; + }; + + @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; +} diff --git a/apps/api/src/oidc/oidc.controller.ts b/apps/api/src/oidc/oidc.controller.ts new file mode 100644 index 0000000..3fcb5cd --- /dev/null +++ b/apps/api/src/oidc/oidc.controller.ts @@ -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 : '中止授权失败', + }); + } + } +} diff --git a/apps/api/src/oidc/oidc.module.ts b/apps/api/src/oidc/oidc.module.ts new file mode 100644 index 0000000..88ee8f9 --- /dev/null +++ b/apps/api/src/oidc/oidc.module.ts @@ -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 {} diff --git a/apps/api/src/oidc/oidc.service.ts b/apps/api/src/oidc/oidc.service.ts new file mode 100644 index 0000000..8005879 --- /dev/null +++ b/apps/api/src/oidc/oidc.service.ts @@ -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('OIDC_ISSUER', 'http://localhost:4000/oidc'); + const cookieSecret = this.configService.get( + 'OIDC_COOKIE_SECRET', + 'oidc-cookie-secret-change-in-production' + ); + + // 解析 JWKS 私钥 + const jwksPrivateKeyBase64 = this.configService.get('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('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); + } +} diff --git a/apps/api/src/oidc/services/account.service.ts b/apps/api/src/oidc/services/account.service.ts new file mode 100644 index 0000000..ebf8b98 --- /dev/null +++ b/apps/api/src/oidc/services/account.service.ts @@ -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 { + 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 => { + 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; + }, + }; + } +} diff --git a/apps/api/src/oidc/services/client.service.ts b/apps/api/src/oidc/services/client.service.ts new file mode 100644 index 0000000..889c4df --- /dev/null +++ b/apps/api/src/oidc/services/client.service.ts @@ -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 }; + } +} diff --git a/apps/api/src/oidc/services/interaction.service.ts b/apps/api/src/oidc/services/interaction.service.ts new file mode 100644 index 0000000..7f6fb99 --- /dev/null +++ b/apps/api/src/oidc/services/interaction.service.ts @@ -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 { + 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 { + // 检查是否需要自动完成授权(第一方应用) + 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 { + 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 { + const result: InteractionResults = { + error, + error_description: errorDescription, + }; + + return provider.interactionResult(ctx.req, ctx.res, result, { + mergeWithLastSubmission: false, + }); + } + + /** + * 检查是否为第一方应用(跳过授权确认) + */ + async isFirstPartyClient(clientId: string): Promise { + const client = await this.prisma.oidcClient.findUnique({ + where: { clientId }, + select: { isFirstParty: true }, + }); + return client?.isFirstParty ?? false; + } +} diff --git a/apps/web/src/app/(dashboard)/oidc-clients/page.tsx b/apps/web/src/app/(dashboard)/oidc-clients/page.tsx new file mode 100644 index 0000000..72bcbda --- /dev/null +++ b/apps/web/src/app/(dashboard)/oidc-clients/page.tsx @@ -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 ( +
+
+
+

OIDC 客户端管理

+

+ 管理 OIDC 客户端应用,配置授权回调地址和权限。 +

+
+
+ + + + 客户端列表 + + 查看和管理所有 OIDC 客户端应用。 + + + + + + +
+ ); +} diff --git a/apps/web/src/app/(oidc)/layout.tsx b/apps/web/src/app/(oidc)/layout.tsx new file mode 100644 index 0000000..fd85214 --- /dev/null +++ b/apps/web/src/app/(oidc)/layout.tsx @@ -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 ( +
+ {/* 顶部导航 */} +
+
+ {siteConfig.name} + +
+
+ + {/* 主内容区 */} +
+
{children}
+
+ + {/* 页脚 */} +
+
+ © {new Date().getFullYear()} {siteConfig.name}. All rights reserved. +
+
+
+ ); +} diff --git a/apps/web/src/app/(oidc)/oidc/error/page.tsx b/apps/web/src/app/(oidc)/oidc/error/page.tsx new file mode 100644 index 0000000..e31a80d --- /dev/null +++ b/apps/web/src/app/(oidc)/oidc/error/page.tsx @@ -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 = { + 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 ( + + + 授权失败 + 无法完成授权流程 + + +

{errorMessage}

+

错误代码: {errorCode}

+
+ + + +
+ ); +} diff --git a/apps/web/src/app/(oidc)/oidc/interaction/[uid]/OidcConsentForm.tsx b/apps/web/src/app/(oidc)/oidc/interaction/[uid]/OidcConsentForm.tsx new file mode 100644 index 0000000..25aee21 --- /dev/null +++ b/apps/web/src/app/(oidc)/oidc/interaction/[uid]/OidcConsentForm.tsx @@ -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(null); + // 默认全部选中 + const [selectedScopes, setSelectedScopes] = useState>( + 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 ( + + +
+ {client.logoUri && ( + {client.clientName} + )} +
+ {client.clientName} + 请求访问您的账户 +
+
+
+
+ +

+ 该应用请求以下权限: +

+ {error && ( +

{error}

+ )} +
    + {requestedScopes.map((scope) => ( +
  • + + handleScopeChange(scope, checked === true) + } + disabled={scope === 'openid' || isPending} + /> + +
  • + ))} +
+
+ + + + +
+
+ ); +} diff --git a/apps/web/src/app/(oidc)/oidc/interaction/[uid]/OidcLoginForm.tsx b/apps/web/src/app/(oidc)/oidc/interaction/[uid]/OidcLoginForm.tsx new file mode 100644 index 0000000..0ad5e12 --- /dev/null +++ b/apps/web/src/app/(oidc)/oidc/interaction/[uid]/OidcLoginForm.tsx @@ -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(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 ( + + +
+ {client.logoUri && ( + {client.clientName} + )} + {client.clientName} +
+ + 登录以继续访问 {client.clientName} + +
+
+ + {error && ( +
+ {error} +
+ )} +
+ + +
+
+ + +
+
+ + + +
+
+ ); +} diff --git a/apps/web/src/app/(oidc)/oidc/interaction/[uid]/page.tsx b/apps/web/src/app/(oidc)/oidc/interaction/[uid]/page.tsx new file mode 100644 index 0000000..d1b0c21 --- /dev/null +++ b/apps/web/src/app/(oidc)/oidc/interaction/[uid]/page.tsx @@ -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 { + 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 ; + } + + if (details.prompt.name === 'consent') { + const requestedScopes = details.params.scope.split(' '); + return ( + + ); + } + + // 未知 prompt 类型 + redirect(`/oidc/error?error=unknown_prompt`); +} diff --git a/apps/web/src/components/oidc-clients/OidcClientCreateDialog.tsx b/apps/web/src/components/oidc-clients/OidcClientCreateDialog.tsx new file mode 100644 index 0000000..2f51fd2 --- /dev/null +++ b/apps/web/src/components/oidc-clients/OidcClientCreateDialog.tsx @@ -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; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function OidcClientCreateDialog({ open, onOpenChange }: Props) { + const [createdSecret, setCreatedSecret] = useState(null); + const createClient = useCreateOidcClient(); + + const form = useForm({ + 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 ( + + + + 客户端创建成功 + + 请妥善保存以下客户端密钥,此密钥仅显示一次。 + + +
+
+

客户端密钥

+

{createdSecret}

+
+
+ + + + +
+
+ ); + } + + return ( + + + + 创建客户端 + 创建一个新的 OIDC 客户端应用。 + +
+ + ( + + 客户端名称 + + + + + + )} + /> + +
+ ( + + 应用类型 + + + + )} + /> + + ( + +
+ 第一方应用 + + 跳过授权确认 + +
+ + + +
+ )} + /> +
+ + ( + + 回调地址 + +