# OIDC Provider 实施文档 本文档描述系统作为 OIDC Provider 对外提供中央鉴权业务的实现方案。 > **实施状态**:✅ 已完成(2025年1月) ## 一、方案概述 基于 [panva/node-oidc-provider](https://github.com/panva/node-oidc-provider) 库实现 OIDC Provider 功能,该库是 OpenID Certified 的成熟实现,支持完整的 OAuth 2.0 和 OpenID Connect 规范。 ### 1.1 核心特性 - **混合存储策略**:PostgreSQL 存储持久化数据,Redis 存储短期/会话数据 - **第一方应用自动授权**:配置 `isFirstParty: true` 的客户端自动跳过授权确认页 - **完整的客户端管理**:支持创建、编辑、删除客户端,以及密钥重新生成 - **标准 OIDC 端点**:支持授权、令牌、用户信息、JWKS、令牌撤销等标准端点 ### 1.2 快速开始 **1. 环境配置** 确保 `.env` 文件包含以下配置: ```bash OIDC_ISSUER=http://localhost:4000/oidc OIDC_COOKIE_SECRET=your-oidc-cookie-secret OIDC_JWKS_PRIVATE_KEY= ``` 生成 JWKS 私钥: ```bash openssl genrsa 2048 | base64 -w 0 ``` **2. 创建客户端** 通过管理界面(/oidc-clients)或 API 创建 OIDC 客户端: ```bash curl -X POST http://localhost:4000/oidc-clients \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "clientName": "我的应用", "redirectUris": ["http://localhost:3001/callback"], "grantTypes": ["authorization_code", "refresh_token"], "scopes": ["openid", "profile", "email"], "isFirstParty": false }' ``` 响应会包含 `clientId` 和 `clientSecret`(仅显示一次)。 **3. 发起授权** 引导用户访问授权端点: ``` http://localhost:4000/oidc/authorize ?client_id= &redirect_uri=http://localhost:3001/callback &response_type=code &scope=openid profile email &state= ``` **4. 获取令牌** 用户授权后,使用授权码换取令牌: ```bash curl -X POST http://localhost:4000/oidc/token \ -u ":" \ -d "grant_type=authorization_code" \ -d "code=" \ -d "redirect_uri=http://localhost:3001/callback" ``` **5. 获取用户信息** 使用 Access Token 获取用户信息: ```bash curl http://localhost:4000/oidc/userinfo \ -H "Authorization: Bearer " ``` ## 二、数据存储设计 ### 2.1 存储策略 根据数据特点采用混合存储策略: | 数据类型 | 存储位置 | 原因 | |----------|----------|------| | OidcClient | PostgreSQL | 客户端配置,需要持久化和管理 | | OidcGrant | PostgreSQL | 用户授权记录,需要审计和长期保存 | | OidcRefreshToken | PostgreSQL | 长期令牌(30天),需要可靠撤销 | | AuthorizationCode | Redis | 短期(10分钟),一次性使用 | | AccessToken | Redis | 短期(1小时),高频访问 | | Session | Redis | 会话数据,可重建 | | Interaction | Redis | 临时交互流程(1小时) | ### 2.2 Prisma 模型(PostgreSQL) 需要在 `apps/api/prisma/schema.prisma` 中添加以下模型: ```prisma // ============ 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") } ``` ### 2.3 Redis 数据结构 短期/临时数据存储在 Redis 中,使用以下 Key 格式: ``` oidc:authorization_code:{code} # 授权码,TTL: 600s oidc:access_token:{jti} # 访问令牌,TTL: 3600s oidc:session:{uid} # OIDC 会话,TTL: 14d oidc:interaction:{uid} # 交互会话,TTL: 3600s ``` Value 为 JSON 序列化的完整数据。 ### 2.4 User 模型关联 在现有 `User` 模型中添加关联: ```prisma model User { // ... 现有字段 ... // OIDC 关联 oidcGrants OidcGrant[] } ``` ## 三、后端模块结构 ### 3.1 目录结构 ``` apps/api/src/oidc/ ├── oidc.module.ts # OIDC 模块定义 ├── oidc.controller.ts # 交互端点控制器(/oidc-interaction) ├── oidc.service.ts # OIDC 核心服务(Provider 初始化与管理) ├── adapters/ │ ├── index.ts # 混合适配器工厂 │ ├── prisma.adapter.ts # Prisma 存储适配器(Client、Grant、RefreshToken) │ └── redis.adapter.ts # Redis 存储适配器(AuthorizationCode、AccessToken、Session、Interaction) ├── controllers/ │ └── client.controller.ts # 客户端管理控制器(/oidc-clients) ├── services/ │ ├── account.service.ts # 账户查找服务(findAccount 实现) │ ├── client.service.ts # 客户端管理服务(CRUD + 密钥生成) │ └── interaction.service.ts # 交互处理服务(登录、授权确认) ├── dto/ │ ├── client.dto.ts # 客户端 DTO(创建、更新、响应) │ └── interaction.dto.ts # 交互 DTO(登录、授权确认) └── config/ └── oidc.config.ts # OIDC Provider 配置 ``` ### 3.2 核心文件实现 #### 3.2.1 混合适配器工厂 (`adapters/index.ts`) ```typescript import { PrismaService } from '@/prisma/prisma.service'; import { RedisService } from '@/common/redis/redis.service'; import { PrismaAdapter } from './prisma.adapter'; import { RedisAdapter } from './redis.adapter'; // Prisma 存储的模型 const PRISMA_MODELS = ['Client', 'Grant', 'RefreshToken']; // Redis 存储的模型 const REDIS_MODELS = ['AuthorizationCode', 'AccessToken', 'Session', 'Interaction']; export function createAdapterFactory( prisma: PrismaService, redis: RedisService ) { return (model: string) => { if (PRISMA_MODELS.includes(model)) { return new PrismaAdapter(prisma, model); } if (REDIS_MODELS.includes(model)) { return new RedisAdapter(redis, model); } throw new Error(`Unknown model: ${model}`); }; } ``` #### 3.2.2 Prisma 适配器 (`adapters/prisma.adapter.ts`) ```typescript import { PrismaService } from '@/prisma/prisma.service'; // oidc-provider 要求的适配器接口(用于 Client、Grant、RefreshToken) export class PrismaAdapter { private model: string; constructor( private prisma: PrismaService, model: string ) { this.model = model; } // 根据 model 名称映射到对应的 Prisma 模型 private getModelDelegate() { const modelMap: Record = { Client: 'oidcClient', RefreshToken: 'oidcRefreshToken', Grant: 'oidcGrant', }; return this.prisma[modelMap[this.model]]; } async upsert(id: string, payload: unknown, expiresIn: number) { /* ... */ } async find(id: string) { /* ... */ } async findByUserCode(userCode: string) { /* ... */ } async findByUid(uid: string) { /* ... */ } async consume(id: string) { /* ... */ } async destroy(id: string) { /* ... */ } async revokeByGrantId(grantId: string) { /* ... */ } } ``` #### 3.2.3 Redis 适配器 (`adapters/redis.adapter.ts`) ```typescript 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 小时 }; // oidc-provider 要求的适配器接口(用于短期数据) export class RedisAdapter { private model: string; private keyPrefix: string; constructor( private redis: RedisService, model: string ) { this.model = model; this.keyPrefix = `${KEY_PREFIX}:${model.toLowerCase()}`; } private key(id: string): string { return `${this.keyPrefix}:${id}`; } async upsert(id: string, payload: unknown, expiresIn: number) { const ttl = expiresIn || MODEL_TTL[this.model] || 3600; await this.redis.setJson(this.key(id), payload, ttl); } async find(id: string) { return this.redis.getJson(this.key(id)); } async findByUid(uid: string) { return this.find(uid); } async consume(id: string) { const data = await this.find(id); if (data) { (data as Record).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) { await this.redis.del(this.key(id)); } async revokeByGrantId(grantId: string) { // Redis 适配器不支持按 grantId 批量撤销 // 需要在业务层处理或使用 Redis SCAN } } ``` #### 3.2.4 账户服务 (`services/account.service.ts`) ```typescript import { Injectable } from '@nestjs/common'; import { PrismaService } from '@/prisma/prisma.service'; @Injectable() export class OidcAccountService { constructor(private prisma: PrismaService) {} // oidc-provider 要求的 findAccount 方法 async findAccount(ctx: unknown, id: string) { 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, async claims(use: string, scope: string) { const claims: Record = { sub: user.id }; if (scope.includes('profile')) { claims.name = user.name; claims.picture = user.avatarId; // 可转换为完整 URL } if (scope.includes('email')) { claims.email = user.email; claims.email_verified = true; } return claims; }, }; } } ``` #### 3.2.5 客户端服务 (`services/client.service.ts`) 客户端 ID 使用 cuid2 生成,客户端密钥使用 64 字符随机十六进制: ```typescript import { randomBytes } from 'crypto'; import { Injectable } from '@nestjs/common'; import { createId } from '@paralleldrive/cuid2'; @Injectable() export class OidcClientService extends CrudService<...> { // 生成客户端 ID(使用 cuid2,格式如 clxxx...) private generateClientId(): string { return createId(); } // 生成客户端密钥(64 字符随机十六进制,无前缀) private generateClientSecret(): string { return randomBytes(32).toString('hex'); } async createClient(dto: CreateOidcClientDto) { const clientId = this.generateClientId(); const clientSecret = this.generateClientSecret(); // ...创建客户端逻辑 } async regenerateSecret(id: string) { const clientSecret = this.generateClientSecret(); // ...更新密钥逻辑 } } ``` #### 3.2.6 OIDC 配置 (`config/oidc.config.ts`) ```typescript import type { Configuration } from 'oidc-provider'; export function createOidcConfiguration( issuer: string, accountService: OidcAccountService, adapterFactory: (model: string) => PrismaAdapter ): Configuration { return { // 适配器工厂 adapter: adapterFactory, // 账户查找 findAccount: accountService.findAccount.bind(accountService), // 支持的功能 features: { devInteractions: { enabled: false }, // 禁用开发模式交互 rpInitiatedLogout: { enabled: true }, resourceIndicators: { 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: [process.env.OIDC_COOKIE_SECRET], long: { signed: true, maxAge: 14 * 24 * 3600 * 1000 }, short: { signed: true }, }, // 交互 URL(重定向到前端) interactions: { url: (ctx, interaction) => `/oidc/interaction/${interaction.uid}`, }, // 路由前缀 routes: { authorization: '/authorize', token: '/token', userinfo: '/userinfo', jwks: '/jwks', revocation: '/revoke', introspection: '/introspect', end_session: '/logout', }, // PKCE 配置 pkce: { methods: ['S256'], required: () => false, // 公开客户端强制要求 }, // 响应类型 responseTypes: ['code', 'code id_token', 'id_token', 'none'], // 授权类型 grantTypes: ['authorization_code', 'refresh_token', 'client_credentials'], }; } ``` #### 3.2.7 OIDC 控制器 (`oidc.controller.ts`) ```typescript import { All, Controller, Get, Post, Req, Res, UseGuards } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiExcludeEndpoint } from '@nestjs/swagger'; import type { Request, Response } from 'express'; import { Public } from '@/auth/decorators/public.decorator'; import { OidcService } from './oidc.service'; @ApiTags('OIDC Provider') @Controller('oidc') export class OidcController { constructor(private readonly oidcService: OidcService) {} // oidc-provider 处理所有标准端点 @All('*') @Public() @ApiExcludeEndpoint() async handleOidc(@Req() req: Request, @Res() res: Response) { return this.oidcService.callback(req, res); } // 交互端点 - 获取交互详情 @Get('interaction/:uid') @Public() @ApiOperation({ summary: '获取 OIDC 交互详情' }) async getInteraction(@Req() req: Request, @Res() res: Response) { const details = await this.oidcService.interactionDetails(req, res); // 返回交互详情供前端渲染 return res.json(details); } // 交互端点 - 提交登录 @Post('interaction/:uid/login') @Public() @ApiOperation({ summary: '提交 OIDC 登录' }) async submitLogin(@Req() req: Request, @Res() res: Response) { return this.oidcService.handleLogin(req, res); } // 交互端点 - 提交授权确认 @Post('interaction/:uid/confirm') @Public() @ApiOperation({ summary: '提交 OIDC 授权确认' }) async submitConsent(@Req() req: Request, @Res() res: Response) { return this.oidcService.handleConsent(req, res); } // 交互端点 - 中止授权 @Post('interaction/:uid/abort') @Public() @ApiOperation({ summary: '中止 OIDC 授权' }) async abortInteraction(@Req() req: Request, @Res() res: Response) { return this.oidcService.handleAbort(req, res); } } ``` #### 3.2.8 客户端管理控制器 (`client.controller.ts`) ```typescript @ApiTags('OIDC 客户端管理') @ApiBearerAuth() @UseGuards(JwtAuthGuard, PermissionGuard) @Controller('oidc-clients') export class OidcClientController { constructor(private readonly clientService: OidcClientService) {} @Get() @RequirePermission('oidc-client:read') @ApiOperation({ summary: '获取客户端列表' }) findAll(@Query() query: PaginationQueryDto) { /* ... */ } @Post() @RequirePermission('oidc-client:create') @ApiOperation({ summary: '创建客户端' }) create(@Body() dto: CreateOidcClientDto) { /* ... */ } @Patch(':id') @RequirePermission('oidc-client:update') @ApiOperation({ summary: '更新客户端' }) update(@Param('id') id: string, @Body() dto: UpdateOidcClientDto) { /* ... */ } @Delete(':id') @RequirePermission('oidc-client:delete') @ApiOperation({ summary: '删除客户端' }) remove(@Param('id') id: string) { /* ... */ } @Post(':id/regenerate-secret') @RequirePermission('oidc-client:update') @ApiOperation({ summary: '重新生成客户端密钥' }) regenerateSecret(@Param('id') id: string) { /* ... */ } } ``` ## 四、OIDC 端点说明 Issuer URL 为 `http://localhost:4000/oidc`,所有标准 OIDC 端点都在 `/oidc` 路径下: | 端点 | 方法 | 说明 | |------|------|------| | `/oidc/.well-known/openid-configuration` | GET | OIDC 发现文档 | | `/oidc/authorize` | GET | 授权端点 | | `/oidc/token` | POST | 令牌端点 | | `/oidc/userinfo` | GET/POST | 用户信息端点 | | `/oidc/jwks` | GET | JSON Web Key Set | | `/oidc/revoke` | POST | 令牌撤销 | | `/oidc/introspect` | POST | 令牌内省 | | `/oidc/logout` | GET/POST | 登出端点 | 自定义交互 API 端点在 `/oidc-interaction` 路径下(避免与 oidc-provider 冲突): | 端点 | 方法 | 说明 | |------|------|------| | `/oidc-interaction/:uid` | GET | 获取交互详情 | | `/oidc-interaction/:uid/login` | POST | 提交登录 | | `/oidc-interaction/:uid/confirm` | POST | 提交授权确认 | | `/oidc-interaction/:uid/abort` | POST | 中止授权 | ## 五、前端改动 ### 5.1 设计原则 OIDC 交互页面采用 SSR(服务端渲染)以优化性能,且独立于现有登录系统: - **独立路由**:使用 `/oidc/` 路由,不复用现有 `(auth)` 路由组 - **独立登录页面**:OIDC 登录页面独立实现,不复用现有登录组件(避免状态冲突) - **授权确认页面**:SSR + Server Actions,首屏直出 - **错误页面**:纯 SSR,无客户端 JS ### 5.2 新增页面 OIDC 交互页面使用独立路由组 `(oidc)`,与现有登录系统完全隔离: ``` apps/web/src/app/(oidc)/ ├── oidc/ │ ├── interaction/[uid]/ │ │ ├── page.tsx # SSR 交互页面(根据 prompt 类型渲染登录或授权确认) │ │ └── actions.ts # Server Actions(登录、授权确认、中止) │ └── error/page.tsx # OIDC 错误页面(SSR) └── layout.tsx # OIDC 专用布局(独立于主站布局) ``` OIDC 客户端管理页面放在 dashboard 路由组中(需要登录后访问): ``` apps/web/src/app/(dashboard)/oidc-clients/page.tsx # 客户端列表 apps/web/src/components/oidc-clients/ ├── OidcClientsTable.tsx # 客户端表格 ├── OidcClientEditDialog.tsx # 编辑对话框 └── index.ts ``` ### 5.3 交互页面(SSR + Server Actions) ```tsx // apps/web/src/app/(oidc)/oidc/interaction/[uid]/page.tsx import { redirect, notFound } from 'next/navigation'; import { cookies } from 'next/headers'; import { ConsentForm } from './ConsentForm'; import { LoginForm } from './LoginForm'; interface InteractionDetails { uid: string; prompt: { name: 'login' | 'consent'; details?: Record }; params: { client_id: string; redirect_uri: string; scope: string; }; client: { clientId: string; clientName: string; logoUri?: string; clientUri?: string; }; session?: { accountId: string; }; } // 服务端获取交互详情 async function getInteractionDetails(uid: string): Promise { const res = await fetch(`${process.env.API_URL}/oidc/interaction/${uid}`, { headers: { Cookie: cookies().toString() }, cache: 'no-store', }); if (!res.ok) return null; return res.json(); } interface Props { params: { uid: string }; } export default async function OidcInteractionPage({ params }: Props) { const details = await getInteractionDetails(params.uid); if (!details) { notFound(); } // 根据 prompt 类型渲染不同组件 if (details.prompt.name === 'login') { return ( ); } if (details.prompt.name === 'consent') { return ( ); } // 未知 prompt 类型 redirect(`/oidc/error?error=unknown_prompt`); } ``` ### 5.4 Server Actions ```typescript // apps/web/src/app/(oidc)/oidc/interaction/[uid]/actions.ts 'use server'; import { cookies } from 'next/headers'; import { redirect } from 'next/navigation'; // 提交登录 export async function submitLogin(uid: string, formData: FormData) { const email = formData.get('email') as string; const password = formData.get('password') as string; const res = await fetch(`${process.env.API_URL}/oidc/interaction/${uid}/login`, { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: cookies().toString(), }, body: JSON.stringify({ email, password }), }); if (!res.ok) { const error = await res.json(); return { error: error.message || '登录失败' }; } // 后端返回重定向 URL const { redirectTo } = await res.json(); redirect(redirectTo); } // 提交授权确认 export async function submitConsent(uid: string, scopes: string[]) { const res = await fetch(`${process.env.API_URL}/oidc/interaction/${uid}/confirm`, { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: cookies().toString(), }, body: JSON.stringify({ scopes }), }); if (!res.ok) { const error = await res.json(); return { error: error.message || '授权失败' }; } const { redirectTo } = await res.json(); redirect(redirectTo); } // 中止授权 export async function abortInteraction(uid: string) { const res = await fetch(`${process.env.API_URL}/oidc/interaction/${uid}/abort`, { method: 'POST', headers: { Cookie: cookies().toString() }, }); const { redirectTo } = await res.json(); redirect(redirectTo); } ``` ### 5.5 授权确认表单组件 ```tsx // apps/web/src/app/(oidc)/oidc/interaction/[uid]/ConsentForm.tsx 'use client'; import { useTransition } 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 { submitConsent, abortInteraction } from './actions'; const SCOPE_DESCRIPTIONS: Record = { openid: '基本身份信息', profile: '个人资料(姓名、头像)', email: '邮箱地址', offline_access: '离线访问(保持登录状态)', }; interface Props { uid: string; client: { clientName: string; logoUri?: string; clientUri?: string }; params: { scope: string }; session?: { accountId: string }; } export function ConsentForm({ uid, client, params }: Props) { const [isPending, startTransition] = useTransition(); const requestedScopes = params.scope.split(' '); const handleSubmit = (formData: FormData) => { const selectedScopes = requestedScopes.filter( (scope) => formData.get(`scope_${scope}`) === 'on' ); startTransition(() => submitConsent(uid, selectedScopes)); }; const handleDeny = () => { startTransition(() => abortInteraction(uid)); }; return (
{client.logoUri && ( )}
{client.clientName} 请求访问您的账户

该应用请求以下权限:

    {requestedScopes.map((scope) => (
  • ))}
); } ``` ## 六、共享类型定义 在 `packages/shared/src/types/oidc.ts` 中添加: ```typescript // 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; } export interface CreateOidcClientDto { clientName: string; clientUri?: string; logoUri?: string; redirectUris: string[]; postLogoutRedirectUris?: string[]; grantTypes?: string[]; responseTypes?: string[]; scopes?: string[]; tokenEndpointAuthMethod?: string; applicationType?: string; isFirstParty?: boolean; } export interface UpdateOidcClientDto { clientName?: string; clientUri?: string; logoUri?: string; redirectUris?: string[]; postLogoutRedirectUris?: string[]; grantTypes?: string[]; responseTypes?: string[]; scopes?: string[]; isEnabled?: boolean; isFirstParty?: boolean; } // OIDC 交互类型 export interface OidcInteractionDetails { uid: string; prompt: { name: 'login' | 'consent'; details?: Record; }; 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; }; session?: { accountId: string; }; } // Scope 描述 export const OidcScopeDescriptions: Record = { openid: '基本身份信息', profile: '个人资料(姓名、头像)', email: '邮箱地址', offline_access: '离线访问(刷新令牌)', }; ``` ## 七、环境变量配置 在 `apps/api/.env.example` 中添加: ```bash # ----- OIDC Provider 配置 ----- # OIDC 签发者 URL(必须是可公开访问的 URL,包含 /oidc 路径) OIDC_ISSUER=http://localhost:4000/oidc # OIDC Cookie 签名密钥(生产环境必须修改) OIDC_COOKIE_SECRET=your-oidc-cookie-secret-change-in-production # OIDC JWKS 私钥(RS256,PEM 格式,Base64 编码) # 生成方式: openssl genrsa 2048 | base64 -w 0 OIDC_JWKS_PRIVATE_KEY= ``` ## 八、安全考虑 1. **PKCE 支持**:公开客户端(SPA、移动应用)强制要求 PKCE 2. **令牌安全**: - Access Token 使用 JWT 格式,支持签名验证 - Refresh Token 存储在数据库,支持撤销 - 短期 Access Token(1小时)+ 长期 Refresh Token(30天) 3. **客户端认证**: - 机密客户端使用 client_secret_basic 或 client_secret_post - 支持 private_key_jwt 高安全认证方式 4. **授权确认**: - 第三方应用必须经过用户授权确认 - **第一方应用自动授权**(见下文详细说明) - 记住用户授权决定,避免重复确认 5. **令牌撤销**: - 支持主动撤销 Access Token 和 Refresh Token - 用户可在设置页面管理已授权的应用 6. **CORS 配置**: - Token 端点需要支持跨域请求 - 配置允许的 Origin 列表 ### 8.1 第一方应用自动授权实现 第一方应用(`isFirstParty: true`)在两种场景下自动跳过授权确认页: **场景 1:登录时自动授权** 用户在 OIDC 登录页面输入凭据后,`finishLogin` 方法检测第一方应用并自动完成授权: ```typescript // interaction.service.ts async finishLogin(provider, ctx, accountId, options) { // 检查是否为第一方应用 const client = await provider.Client.find(options.clientId); const isFirstParty = client?.['urn:custom:first_party']; if (isFirstParty) { // 第一方应用:登录后自动完成授权 const grant = new provider.Grant({ accountId, clientId: options.clientId }); options.scopes.forEach(scope => grant.addOIDCScope(scope)); const grantId = await grant.save(); return provider.interactionFinished(ctx.req, ctx.res, { login: { accountId }, consent: { grantId }, }, { mergeWithLastSubmission: true }); } // 第三方应用:仅完成登录,等待授权确认 return provider.interactionFinished(ctx.req, ctx.res, { login: { accountId }, }, { mergeWithLastSubmission: false }); } ``` **场景 2:已登录用户访问时自动授权** 用户已在系统登录,访问第一方应用的授权 URL 时,`getInteraction` 接口检测并自动完成授权: ```typescript // oidc.controller.ts async getInteraction(req, res, uid) { const details = await this.oidcService.interactionDetails(req, res); const client = await provider.Client.find(details.params.client_id); // 检查:consent prompt + 已登录 + 第一方应用 if ( details.prompt.name === 'consent' && details.session?.accountId && client?.['urn:custom:first_party'] ) { const scopes = details.params.scope.split(' '); const redirectTo = await this.interactionService.finishConsent(...); // 返回自动授权标记,前端直接重定向 return res.json({ autoConsent: true, redirectTo }); } // 返回交互详情,前端渲染登录或授权页面 return res.json({ uid, prompt, params, client, session }); } ``` **前端处理自动授权**: ```tsx // page.tsx const details = await getInteractionDetails(uid); // 第一方应用自动授权,直接重定向 if (details.autoConsent && details.redirectTo) { redirect(details.redirectTo); } ``` ### 8.2 Grant Scope 存储 oidc-provider 的 Grant payload 中,scope 存储在嵌套结构中: ```typescript // prisma.adapter.ts - Grant 模型存储逻辑 case 'Grant': { // payload 结构: { openid: { scope: "openid profile email" } } const openid = payload.openid as { scope?: string } | undefined; const scopeString = openid?.scope || ''; await this.prisma.oidcGrant.upsert({ where: { grantId: id }, update: { data: payload, scope: scopeString, expiresAt }, create: { grantId: id, clientId: payload.clientId, userId: payload.accountId, scope: scopeString, data: payload, expiresAt, }, }); } ``` ## 九、实施步骤 > **状态**:全部阶段已完成 ✅ ### 阶段一:基础设施 ✅ 1. ✅ 添加 Prisma 数据模型(OidcClient、OidcGrant、OidcRefreshToken) 2. ✅ 实现混合适配器(Prisma + Redis) 3. ✅ 配置 oidc-provider 4. ✅ 集成到 NestJS 应用(Express 中间件挂载) ### 阶段二:核心功能 ✅ 1. ✅ 实现账户服务(findAccount) 2. ✅ 实现交互处理(登录、授权确认、中止) 3. ✅ 实现客户端管理 CRUD 4. ✅ 添加权限控制(oidc-client:read/create/update/delete) 5. ✅ 实现第一方应用自动授权 ### 阶段三:前端页面 ✅ 1. ✅ OIDC 登录页面(独立于现有登录系统) 2. ✅ 授权确认页面(scope 选择) 3. ✅ OIDC 客户端管理页面(表格 + 创建/编辑弹窗) 4. ✅ 客户端密钥重新生成功能 ### 阶段四:测试与完善 ✅ 1. ✅ 端到端授权流程测试 2. ✅ 第一方应用自动授权测试 3. ✅ API 文档完善(Swagger) 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 数据模型(OidcClient、OidcGrant、OidcRefreshToken) | | `apps/api/src/oidc/oidc.module.ts` | OIDC 模块定义 | | `apps/api/src/oidc/oidc.service.ts` | Provider 初始化与管理 | | `apps/api/src/oidc/oidc.controller.ts` | 交互端点(获取详情、登录、授权、中止) | | `apps/api/src/oidc/adapters/index.ts` | 混合适配器工厂 | | `apps/api/src/oidc/adapters/prisma.adapter.ts` | PostgreSQL 存储适配器 | | `apps/api/src/oidc/adapters/redis.adapter.ts` | Redis 存储适配器 | | `apps/api/src/oidc/services/account.service.ts` | 账户查找服务 | | `apps/api/src/oidc/services/client.service.ts` | 客户端管理(cuid2 ID + 随机密钥) | | `apps/api/src/oidc/services/interaction.service.ts` | 交互处理(含第一方自动授权) | | `apps/api/src/oidc/controllers/client.controller.ts` | 客户端管理 API | | `apps/api/src/oidc/config/oidc.config.ts` | OIDC Provider 配置 | | `apps/api/src/oidc/dto/client.dto.ts` | 客户端 DTO | | `apps/api/src/oidc/dto/interaction.dto.ts` | 交互 DTO | ### 前端文件 | 文件 | 说明 | |------|------| | `apps/web/src/app/(oidc)/layout.tsx` | OIDC 专用布局 | | `apps/web/src/app/(oidc)/oidc/interaction/[uid]/page.tsx` | 交互页面(SSR) | | `apps/web/src/app/(oidc)/oidc/interaction/[uid]/OidcLoginForm.tsx` | 登录表单 | | `apps/web/src/app/(oidc)/oidc/interaction/[uid]/OidcConsentForm.tsx` | 授权确认表单 | | `apps/web/src/app/(oidc)/oidc/error/page.tsx` | OIDC 错误页面 | | `apps/web/src/app/(dashboard)/oidc-clients/page.tsx` | 客户端管理页面 | | `apps/web/src/components/oidc-clients/OidcClientsTable.tsx` | 客户端列表表格 | | `apps/web/src/components/oidc-clients/OidcClientCreateDialog.tsx` | 创建客户端弹窗 | | `apps/web/src/components/oidc-clients/OidcClientEditDialog.tsx` | 编辑客户端弹窗 | | `apps/web/src/hooks/useOidcClients.ts` | 客户端管理 hooks | | `apps/web/src/services/oidc-client.service.ts` | 客户端 API 服务 | ### 共享类型 | 文件 | 说明 | |------|------| | `packages/shared/src/types/oidc.ts` | OIDC 共享类型定义 | | `packages/shared/src/types/index.ts` | 类型导出(包含 OIDC 类型) | ## 十二、参考资料 - [panva/node-oidc-provider](https://github.com/panva/node-oidc-provider) - [node-oidc-provider Documentation](https://github.com/panva/node-oidc-provider/blob/main/docs/README.md) - [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html) - [OAuth 2.0 RFC 6749](https://tools.ietf.org/html/rfc6749)