- 后端:基于 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>
1044 lines
30 KiB
Markdown
1044 lines
30 KiB
Markdown
# 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 私钥(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 列表
|
||
|
||
## 九、实施步骤
|
||
|
||
### 阶段一:基础设施(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)
|