Files
seclusion/docs/backend/oidc-provider.md
charilezhou 3943bd112f docs: 更新 OIDC Provider 文档并完善环境配置
文档更新:
- 从"实施方案"改为"实施文档",标记为已完成状态
- 添加快速开始章节,提供完整的使用示例
- 补充第一方应用自动授权的两种场景实现细节
- 补充 Grant Scope 存储的 payload 结构说明
- 新增客户端服务章节(cuid2 ID + 随机密钥)
- 更新关键文件清单(后端/前端/共享类型)

环境配置:
- 添加 FRONTEND_URL 配置
- 添加 OIDC Provider 开发环境配置

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 17:29:26 +08:00

1294 lines
39 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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=<Base64编码的RSA私钥>
```
生成 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 <token>" \
-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=<clientId>
&redirect_uri=http://localhost:3001/callback
&response_type=code
&scope=openid profile email
&state=<random_state>
```
**4. 获取令牌**
用户授权后,使用授权码换取令牌:
```bash
curl -X POST http://localhost:4000/oidc/token \
-u "<clientId>:<clientSecret>" \
-d "grant_type=authorization_code" \
-d "code=<authorization_code>" \
-d "redirect_uri=http://localhost:3001/callback"
```
**5. 获取用户信息**
使用 Access Token 获取用户信息:
```bash
curl http://localhost:4000/oidc/userinfo \
-H "Authorization: Bearer <access_token>"
```
## 二、数据存储设计
### 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<string, keyof PrismaService> = {
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<string, number> = {
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<string, unknown>).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<string, unknown> = { 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<string, unknown> };
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<InteractionDetails | null> {
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 (
<LoginForm
uid={details.uid}
client={details.client}
params={details.params}
/>
);
}
if (details.prompt.name === 'consent') {
return (
<ConsentForm
uid={details.uid}
client={details.client}
params={details.params}
session={details.session}
/>
);
}
// 未知 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<string, string> = {
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 (
<Card className="w-full max-w-md mx-auto">
<CardHeader>
<div className="flex items-center gap-4">
{client.logoUri && (
<img src={client.logoUri} alt="" className="w-12 h-12 rounded" />
)}
<div>
<CardTitle>{client.clientName}</CardTitle>
<CardDescription>请求访问您的账户</CardDescription>
</div>
</div>
</CardHeader>
<form action={handleSubmit}>
<CardContent>
<p className="mb-4 text-sm text-muted-foreground">该应用请求以下权限:</p>
<ul className="space-y-3">
{requestedScopes.map((scope) => (
<li key={scope} className="flex items-center gap-3">
<Checkbox
id={`scope_${scope}`}
name={`scope_${scope}`}
defaultChecked
disabled={scope === 'openid'} // openid 必选
/>
<label htmlFor={`scope_${scope}`} className="text-sm">
{SCOPE_DESCRIPTIONS[scope] || scope}
</label>
</li>
))}
</ul>
</CardContent>
<CardFooter className="flex gap-4">
<Button type="button" variant="outline" onClick={handleDeny} disabled={isPending}>
拒绝
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? '处理中...' : '授权'}
</Button>
</CardFooter>
</form>
</Card>
);
}
```
## 六、共享类型定义
`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<string, unknown>;
};
params: {
client_id: string;
redirect_uri: string;
scope: string;
response_type: string;
state?: string;
nonce?: string;
};
client: {
clientId: string;
clientName: string;
logoUri?: string;
clientUri?: string;
};
session?: {
accountId: string;
};
}
// Scope 描述
export const OidcScopeDescriptions: Record<string, string> = {
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 私钥RS256PEM 格式Base64 编码)
# 生成方式: openssl genrsa 2048 | base64 -w 0
OIDC_JWKS_PRIVATE_KEY=
```
## 八、安全考虑
1. **PKCE 支持**公开客户端SPA、移动应用强制要求 PKCE
2. **令牌安全**
- Access Token 使用 JWT 格式,支持签名验证
- Refresh Token 存储在数据库,支持撤销
- 短期 Access Token1小时+ 长期 Refresh Token30天
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)