文档更新: - 从"实施方案"改为"实施文档",标记为已完成状态 - 添加快速开始章节,提供完整的使用示例 - 补充第一方应用自动授权的两种场景实现细节 - 补充 Grant Scope 存储的 payload 结构说明 - 新增客户端服务章节(cuid2 ID + 随机密钥) - 更新关键文件清单(后端/前端/共享类型) 环境配置: - 添加 FRONTEND_URL 配置 - 添加 OIDC Provider 开发环境配置 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
39 KiB
OIDC Provider 实施文档
本文档描述系统作为 OIDC Provider 对外提供中央鉴权业务的实现方案。
实施状态:✅ 已完成(2025年1月)
一、方案概述
基于 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 文件包含以下配置:
OIDC_ISSUER=http://localhost:4000/oidc
OIDC_COOKIE_SECRET=your-oidc-cookie-secret
OIDC_JWKS_PRIVATE_KEY=<Base64编码的RSA私钥>
生成 JWKS 私钥:
openssl genrsa 2048 | base64 -w 0
2. 创建客户端
通过管理界面(/oidc-clients)或 API 创建 OIDC 客户端:
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. 获取令牌
用户授权后,使用授权码换取令牌:
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 获取用户信息:
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 中添加以下模型:
// ============ 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 模型中添加关联:
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)
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)
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)
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)
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 字符随机十六进制:
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)
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)
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)
@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)
// 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
// 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 授权确认表单组件
// 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 中添加:
// 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 中添加:
# ----- 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=
八、安全考虑
- PKCE 支持:公开客户端(SPA、移动应用)强制要求 PKCE
- 令牌安全:
- Access Token 使用 JWT 格式,支持签名验证
- Refresh Token 存储在数据库,支持撤销
- 短期 Access Token(1小时)+ 长期 Refresh Token(30天)
- 客户端认证:
- 机密客户端使用 client_secret_basic 或 client_secret_post
- 支持 private_key_jwt 高安全认证方式
- 授权确认:
- 第三方应用必须经过用户授权确认
- 第一方应用自动授权(见下文详细说明)
- 记住用户授权决定,避免重复确认
- 令牌撤销:
- 支持主动撤销 Access Token 和 Refresh Token
- 用户可在设置页面管理已授权的应用
- CORS 配置:
- Token 端点需要支持跨域请求
- 配置允许的 Origin 列表
8.1 第一方应用自动授权实现
第一方应用(isFirstParty: true)在两种场景下自动跳过授权确认页:
场景 1:登录时自动授权
用户在 OIDC 登录页面输入凭据后,finishLogin 方法检测第一方应用并自动完成授权:
// 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 接口检测并自动完成授权:
// 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 });
}
前端处理自动授权:
// page.tsx
const details = await getInteractionDetails(uid);
// 第一方应用自动授权,直接重定向
if (details.autoConsent && details.redirectTo) {
redirect(details.redirectTo);
}
8.2 Grant Scope 存储
oidc-provider 的 Grant payload 中,scope 存储在嵌套结构中:
// 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,
},
});
}
九、实施步骤
状态:全部阶段已完成 ✅
阶段一:基础设施 ✅
- ✅ 添加 Prisma 数据模型(OidcClient、OidcGrant、OidcRefreshToken)
- ✅ 实现混合适配器(Prisma + Redis)
- ✅ 配置 oidc-provider
- ✅ 集成到 NestJS 应用(Express 中间件挂载)
阶段二:核心功能 ✅
- ✅ 实现账户服务(findAccount)
- ✅ 实现交互处理(登录、授权确认、中止)
- ✅ 实现客户端管理 CRUD
- ✅ 添加权限控制(oidc-client:read/create/update/delete)
- ✅ 实现第一方应用自动授权
阶段三:前端页面 ✅
- ✅ OIDC 登录页面(独立于现有登录系统)
- ✅ 授权确认页面(scope 选择)
- ✅ OIDC 客户端管理页面(表格 + 创建/编辑弹窗)
- ✅ 客户端密钥重新生成功能
阶段四:测试与完善 ✅
- ✅ 端到端授权流程测试
- ✅ 第一方应用自动授权测试
- ✅ API 文档完善(Swagger)
- ✅ 实施文档更新
十、NestJS 集成要点
10.1 Issuer URL 配置
Issuer URL 可以包含路径前缀,如 http://localhost:4000/oidc:
- 发现文档路径:
/oidc/.well-known/openid-configuration - 授权端点:
/oidc/authorize - 令牌端点:
/oidc/token
这符合 RFC 8414 规范,.well-known 路径是相对于 Issuer URL 的。
10.2 中间件挂载方式
问题:NestJS Controller 的 @All('*') 无法正确捕获 oidc-provider 的所有路由(如 .well-known 路径)。
解决方案:使用 Express 原生方式在 main.ts 中挂载:
// 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() 移到构造函数中:
// ❌ 错误: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 类型) |