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

39 KiB
Raw Blame History

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
  }'

响应会包含 clientIdclientSecret(仅显示一次)。

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 私钥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 方法检测第一方应用并自动完成授权:

// 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,
    },
  });
}

九、实施步骤

状态:全部阶段已完成

阶段一:基础设施

  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 规范,.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 类型)

十二、参考资料