Files
seclusion/docs/backend/oidc-provider.md
charilezhou 90513e8278 feat: 实现完整的 OIDC Provider 功能
- 后端:基于 node-oidc-provider 实现 OIDC Provider
  - 支持 authorization_code、refresh_token、client_credentials 授权类型
  - Redis adapter 存储会话数据,Prisma adapter 存储持久化数据
  - 客户端管理 CRUD API(创建、更新、删除、重新生成密钥)
  - 交互 API(登录、授权确认、中止)
  - 第一方应用自动跳过授权确认页面
  - 使用 cuid2 生成客户端 ID

- 前端:OIDC 客户端管理界面
  - 客户端列表表格(支持分页、排序)
  - 创建/编辑弹窗(支持所有 OIDC 配置字段)
  - OIDC 交互页面(登录表单、授权确认表单)

- 共享类型:添加 OIDC 相关 TypeScript 类型定义

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

1044 lines
30 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对外提供中央鉴权业务。
## 一、方案概述
基于 [panva/node-oidc-provider](https://github.com/panva/node-oidc-provider) 库实现 OIDC Provider 功能,该库是 OpenID Certified 的成熟实现,支持完整的 OAuth 2.0 和 OpenID Connect 规范。
## 二、数据存储设计
### 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 端点控制器
├── oidc.service.ts # OIDC 核心服务
├── adapters/
│ ├── prisma.adapter.ts # Prisma 存储适配器Client、Grant、RefreshToken
│ └── redis.adapter.ts # Redis 存储适配器AuthorizationCode、AccessToken、Session、Interaction
├── services/
│ ├── account.service.ts # 账户查找服务
│ ├── client.service.ts # 客户端管理服务
│ └── interaction.service.ts # 交互处理服务
├── dto/
│ ├── client.dto.ts # 客户端 DTO
│ ├── consent.dto.ts # 授权确认 DTO
│ └── interaction.dto.ts # 交互 DTO
├── guards/
│ └── oidc-interaction.guard.ts # 交互会话守卫
└── 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 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.6 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.7 客户端管理控制器 (`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 列表
## 九、实施步骤
### 阶段一基础设施1-2天
1. 添加 Prisma 数据模型
2. 实现 Prisma 适配器
3. 配置 oidc-provider
4. 集成到 NestJS 应用
### 阶段二核心功能2-3天
1. 实现账户服务
2. 实现交互处理(登录、授权确认)
3. 实现客户端管理 CRUD
4. 添加权限控制
### 阶段三前端页面1-2天
1. 授权确认页面
2. OIDC 客户端管理页面
3. 用户已授权应用管理
### 阶段四测试与文档1天
1. 单元测试
2. 集成测试
3. API 文档完善
4. 使用文档
## 十、NestJS 集成要点
### 10.1 Issuer URL 配置
Issuer URL 可以包含路径前缀,如 `http://localhost:4000/oidc`
- 发现文档路径:`/oidc/.well-known/openid-configuration`
- 授权端点:`/oidc/authorize`
- 令牌端点:`/oidc/token`
这符合 [RFC 8414](https://tools.ietf.org/html/rfc8414) 规范,`.well-known` 路径是相对于 Issuer URL 的。
### 10.2 中间件挂载方式
**问题**NestJS Controller 的 `@All('*')` 无法正确捕获 oidc-provider 的所有路由(如 `.well-known` 路径)。
**解决方案**:使用 Express 原生方式在 `main.ts` 中挂载:
```typescript
// main.ts
const oidcService = app.get(OidcService);
const provider = oidcService.getProvider();
if (provider) {
const expressApp = app.getHttpAdapter().getInstance();
expressApp.use('/oidc', provider.callback());
}
```
### 10.3 初始化时机(关键)
**问题**`onModuleInit()` 生命周期钩子在 `NestFactory.create()` 返回后执行,但中间件需要在路由注册前挂载。
**解决方案**:将 Provider 初始化从 `onModuleInit()` 移到**构造函数**中:
```typescript
// ❌ 错误onModuleInit 执行太晚,中间件挂载时 provider 还是 undefined
@Injectable()
export class OidcService implements OnModuleInit {
async onModuleInit() {
this.provider = new Provider(issuer, config);
}
}
// ✅ 正确:构造函数中同步初始化,确保 app.get() 时 provider 已就绪
@Injectable()
export class OidcService {
constructor(...deps) {
this.initializeProvider();
}
}
```
### 10.4 挂载顺序
中间件必须在 NestJS 路由注册之前挂载,正确的启动顺序:
```
NestFactory.create()
→ 挂载 oidc-provider 中间件Express 原生方式)
→ app.useGlobalPipes()
→ app.enableCors()
→ app.listen()
```
### 10.5 与 NestJS Controller 共存
为避免路由冲突oidc-provider 和 NestJS Controller 使用不同的路径前缀:
| 处理者 | 路径前缀 | 端点 |
|--------|----------|------|
| oidc-provider | `/oidc` | 标准 OIDC 端点:`.well-known``/authorize``/token``/userinfo``/jwks` 等 |
| NestJS Controller | `/oidc-interaction` | 自定义交互 API`/:uid``/:uid/login``/:uid/confirm``/:uid/abort` |
| NestJS Controller | `/oidc-clients` | 客户端管理 API |
oidc-provider 中间件挂载在 `/oidc` 路径下,会先执行。自定义交互 API 放在 `/oidc-interaction` 路径下,由 NestJS 路由处理,避免被 oidc-provider 拦截。
## 十一、关键文件清单
| 文件 | 说明 |
|------|------|
| `apps/api/src/main.ts` | 应用入口,挂载 oidc-provider 中间件 |
| `apps/api/prisma/schema.prisma` | 添加 OIDC 相关数据模型 |
| `apps/api/src/oidc/` | OIDC 模块目录 |
| `apps/api/src/auth/auth.service.ts` | 参考现有认证逻辑 |
| `apps/web/src/app/(oidc)/` | OIDC 交互页面(登录、授权确认) |
| `apps/web/src/app/(dashboard)/oidc-clients/` | OIDC 客户端管理页面 |
| `packages/shared/src/types/oidc.ts` | OIDC 共享类型定义 |
## 十二、参考资料
- [panva/node-oidc-provider](https://github.com/panva/node-oidc-provider)
- [node-oidc-provider Documentation](https://github.com/panva/node-oidc-provider/blob/main/docs/README.md)
- [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)