---
url: 'https://www.corbado.com/zh/blog/ruhe-goujian-keyanzheng-pingzheng-qianfazhe'
title: '如何构建数字凭证签发者（开发者指南）'
description: '学习如何使用 OpenID4VCI 协议构建一个 W3C 可验证凭证签发者。这份分步指南将向你展示如何创建一个 Next.js 应用程序，用于签发与数字钱包兼容的、经过加密签名的凭证。'
lang: 'zh'
author: 'Amine'
date: '2025-08-20T15:38:53.882Z'
lastModified: '2026-03-25T10:04:42.494Z'
keywords: '数字凭证签发者, 签发者教程, 构建签发者'
category: 'Digital Credentials'
---

# 如何构建数字凭证签发者（开发者指南）

## 1. 引言

数字凭证是一种以安全、保护隐私的方式证明身份和声明的强大工具。但用户最初是如何获得这些凭证的呢？这时，**签发者 (Issuer)**
的角色就至关重要了。签发者是一个受信任的实体，例如[政府](https://www.corbado.com/passkeys-for-public-sector)机构、大学或银行，负责创建并向用户分发经过数字签名的凭证。

本指南提供了一个全面、分步的教程，教我们如何构建一个数字凭证签发者。我们将重点关注
**OpenID for Verifiable Credential Issuance (OpenID4VCI)**
协议，这是一个现代标准，定义了用户如何从签发者处获取凭证，并将其安全地存储在他们的数字钱包中。

最终，我们将构建一个功能齐全的 [Next.js](https://www.corbado.com/blog/nextjs-passkeys) 应用程序，它能够：

1. 通过一个简单的网页表单接受用户数据。
2. 生成一个安全、一次性的凭证邀约 (credential offer)。
3. 将邀约显示为二维码，供用户使用他们的移动钱包扫描。
4. 签发一份经过加密签名的凭证，用户可以存储并出示以供验证。

### 1.1 理解术语：数字凭证 vs. 可验证凭证

在继续之前，我们有必要澄清两个相关但不同的概念之间的区别：

- **数字凭证 (Digital Credentials - 通用术语):**
  这是一个宽泛的类别，涵盖任何数字形式的凭证、证书或证明。这些可以包括简单的数字证书、基础的数字徽章，或任何以电子方式存储的、可能具备也可能不具备加密安全功能的凭证。

- **可验证凭证 (Verifiable Credentials, VCs - W3C 标准):**
  这是一种遵循 W3C 可验证凭证数据模型标准的特定类型的数字凭证。可验证凭证是经过加密签名、防篡改且尊重隐私的凭证，可以被独立验证。它们包含特定的技术要求，例如：
    - 用于保证真实性和完整性的加密签名
    - 标准化的数据模型和格式
    - 保护隐私的出示机制
    - 可互操作的验证协议

**在本指南中，我们专门构建一个遵循 W3C 标准的可验证凭证签发者**，而不仅仅是任何数字凭证系统。我们使用的
[OpenID4VCI](https://www.corbado.com/glossary/openid4vci)
协议是专为签发可验证凭证而设计的，而我们将实现的 JWT-VC 格式是 W3C 兼容的可验证凭证格式。

### 1.2 工作原理

数字凭证背后的奥秘在于一个简单而强大的“**信任三角**”模型，涉及三个关键角色：

- **签发者 (Issuer):**
  一个受信任的权威机构（例如，[政府](https://www.corbado.com/passkeys-for-public-sector)机构、大学或银行），它对凭证进行加密签名并签发给用户。**这正是我们在本指南中要构建的角色。**
- **持有者 (Holder):** 用户，他们接收凭证并将其安全地存储在自己设备上的个人数字钱包中。
- **验证者 (Verifier):** 需要检查用户凭证的应用程序或服务。

![W3C 可验证凭证生态系统](https://www.w3.org/TR/vc-data-model/diagrams/ecosystem.svg)

签发流程是这个生态系统的第一步。签发者验证用户信息，并向他们提供凭证。一旦持有者在其钱包中拥有此凭证，他们就可以向验证者出示它来证明自己的身份或声明，从而完成这个三角关系。

以下是最终应用程序运行的快速演示：

**第 1 步：用户数据输入** 用户填写表单，提供个人信息以申请新凭证。
![用户数据输入表单](https://s3.eu-central-1.amazonaws.com/corbado-cloud-staging-website-assets/issuer_step_1_0733a9e1da.png)

**第 2 步：生成凭证邀约** 应用程序生成一个安全的凭证邀约，并以二维码和预授权码的形式显示。
![凭证邀约二维码](https://s3.eu-central-1.amazonaws.com/corbado-cloud-staging-website-assets/issuer_step_2_3f1881c473.png)

**第 3 步：钱包交互** 用户使用兼容的钱包（例如 Sphereon
[Wallet](https://www.corbado.com/blog/digital-wallet-assurance)）扫描二维码，并输入 PIN 码以授权签发。
![钱包上的凭证邀约](https://s3.eu-central-1.amazonaws.com/corbado-cloud-staging-website-assets/issuer_step_3_b80d689dfe.png)
![输入 PIN 码](https://s3.eu-central-1.amazonaws.com/corbado-cloud-staging-website-assets/issuer_step_4_ca8bad8d11.png)

**第 4 步：凭证已签发** 钱包接收并存储新签发的数字凭证，以备将来使用。
![确认凭证详情](https://s3.eu-central-1.amazonaws.com/corbado-cloud-staging-website-assets/issuer_step_5_55b8150597.png)
![凭证已添加](https://s3.eu-central-1.amazonaws.com/corbado-cloud-staging-website-assets/issuer_step_6_7f5ac5745d.png)

## 2. 构建签发者的先决条件

在我们深入研究代码之前，让我们先了解需要掌握的基础知识和工具。本指南假设你对 Web 开发概念有基本的了解，但以下先决条件对于构建凭证签发者至关重要。

### 2.1 协议选择

我们的签发者建立在一套开放标准之上，以确保钱包和签发服务之间的互操作性。在本教程中，我们将重点关注以下几点：

| 标准 / 协议                                                       | 描述                                                                                                                                                           |
| :---------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **OpenID4VCI**                                                    | **OpenID for Verifiable Credential Issuance**。这是我们将使用的核心协议。它定义了一个标准流程，规定用户（通过其钱包）如何从签发者请求并接收凭证。              |
| **[JWT-VC](https://www.w3.org/TR/vc-data-model/#json-web-token)** | **基于 JWT 的可验证凭证**。这是我们将签发的凭证格式。它是一个 W3C 标准，将可验证凭证编码为 JSON Web Tokens (JWTs)，使其紧凑且适合 Web 环境。                   |
| **[ISO mDoc](https://www.iso.org/standard/69084.html)**           | **ISO/IEC 18013-5**。移动驾照 (mDL) 的国际标准。虽然我们签发的是 JWT-VC，但其中的 _claims_（声明）结构与 mDoc 数据模型兼容（例如 `eu.europa.ec.eudi.pid.1`）。 |
| **OAuth 2.0**                                                     | OpenID4VCI 底层使用的授权框架。我们将实现一个 `pre-authorized_code` 流程，这是一种专为安全、用户友好的凭证签发而设计的特定授权类型。                           |

#### 2.1.1 授权流程：预授权码 vs. 授权码

[OpenID4VCI](https://www.corbado.com/glossary/openid4vci) 支持两种主要的凭证签发授权流程：

1. **预授权码流程 (Pre-Authorized Code Flow):**
   在此流程中，签发者生成一个短暂、一次性的代码 (`pre-authorized_code`)，并立即提供给用户。用户的钱包可以直接用此代码换取凭证。此流程非常适合用户已经过身份验证并正在访问签发者网站的场景，因为它提供了无缝、即时的签发体验，无需重定向。

2. **授权码流程 (Authorization Code Flow):** 这是标准的 [OAuth 2.0](https://www.corbado.com/glossary/oauth2)
   流程，用户被重定向到授权服务器以授予同意。批准后，服务器会将一个 `authorization_code`
   发送回注册的 `redirect_uri`。此流程更适合代表用户发起签发过程的第三方应用程序。

**在本教程中，我们将使用 `pre-authorized_code` 流程。**
我们选择这种方法是因为它更简单，并且为我们的特定用例（用户直接从签发者自己的网站申请凭证）提供了更直接的用户体验。它避免了复杂的重定向和客户端注册，使核心签发逻辑更易于理解和实现。

这些标准的结合使我们能够构建一个与各种数字钱包兼容的签发者，并确保为用户提供一个安全、标准化的流程。

### 2.2 技术栈选择

为了构建我们的签发者，我们将使用与验证者相同的稳健、现代的技术栈，以确保一致且高质量的开发体验。

#### 2.2.1 语言：TypeScript

我们将为前端和后端代码都使用
**TypeScript**。它的静态类型在一个像签发者这样对安全要求很高的应用中非常有价值，因为它有助于防止常见错误，并提高代码的整体质量和可维护性。

#### 2.2.2 框架：Next.js

**Next.js** 是我们的首选框架，因为它为构建全栈应用程序提供了无缝、集成的体验。

- **前端：** 我们将使用带有 [React](https://www.corbado.com/blog/react-passkeys) 的
  [Next.js](https://www.corbado.com/blog/nextjs-passkeys) 来构建用户界面，用户可以在此输入数据以申请凭证。
- **后端：** 我们将利用 **Next.js API Routes** 来创建服务器端端点，处理
  [OpenID4VCI](https://www.corbado.com/glossary/openid4vci) 流程，从生成凭证邀约到签发最终的签名凭证。

#### 2.2.3 关键库

我们的实现将依赖一些关键库来处理特定任务：

- **next**、**react** 和 **react-dom**：我们 [Next.js](https://www.corbado.com/blog/nextjs-passkeys)
  应用程序的核心库。
- **mysql2**：一个用于 [Node.js](https://www.corbado.com/blog/nodejs-passkeys) 的
  [MySQL](https://www.corbado.com/blog/passkey-webauthn-database-guide) 客户端，用于存储授权码和会话数据。
- **uuid**：一个用于生成唯一标识符的库，我们将用它来创建 `pre-authorized_code` 值。
- **jose**：一个强大的库，用于处理 JSON Web Signatures
  (JWS)，我们将用它来对我们签发的凭证进行加密签名。

### 2.3 获取一个测试钱包

要测试你的签发者，你需要一个支持 OpenID4VCI 协议的移动钱包。在本教程中，我们推荐
**Sphereon Wallet**，它同时支持 [Android](https://www.corbado.com/blog/how-to-enable-passkeys-android) 和
[iOS](https://www.corbado.com/blog/how-to-enable-passkeys-ios)。

**如何安装 Sphereon Wallet：**

1. 从
   [Google Play Store](https://play.google.com/store/apps/details?id=com.sphereon.ssi.wallet)
   或 [Apple App Store](https://apps.apple.com/us/app/sphereon-wallet/id1661096796)
   **下载钱包**。
2. 在你的移动设备上安装该应用。
3. 安装后，钱包就可以通过扫描二维码来接收凭证邀约了。

### 2.4 加密知识

签发凭证是一项对安全要求极高的操作，它依赖于基本的加密概念来确保信任和真实性。

#### 2.4.1 数字签名

可验证凭证的核心是一组由签发者**数字签名**的声明。这个签名提供了两个保证：

- **真实性 (Authenticity):** 证明凭证是由合法的签发者创建的。
- **完整性 (Integrity):** 证明凭证自签发以来未被篡改。

#### 2.4.2 公钥/私钥加密

数字签名是使用公钥/私钥加密技术创建的。其工作原理如下：

1. **签发者拥有一对密钥：** 一个**私钥**（保密且安全）和一个相应的**公钥**（公开可用）。
2. **签名：** 当签发者创建凭证时，它使用其**私钥**为凭证数据生成一个唯一的数字签名。
3. **验证：**
   验证者之后可以使用签发者的**公钥**来检查签名。如果检查通过，验证者就知道该凭证是真实的且未被更改。

在我们的实现中，我们将生成一个椭圆曲线 (EC) 密钥对，并使用 `ES256`
算法来签署 JWT-VC。公钥被嵌入到签发者的 DID
(`did:web`) 中，允许任何验证者发现它并验证凭证的签名。 **注意：** 我们的 JWT 中有意省略了
`aud`
(audience) 声明，因为该凭证设计为通用目的，不与特定钱包绑定。如果你想将使用范围限制在特定受众，请包含一个
`aud` 声明并相应地进行设置。

## 3. 架构概览

我们的签发者应用程序是作为一个全栈 Next.js 项目构建的，前端和后端逻辑清晰分离。这种架构使我们能够创建无缝的用户体验，同时在服务器上处理所有对安全要求至关重要的操作。
**重要提示：** SQL 中包含的 `verification_sessions` 和 `verified_credentials`
表对于此签发者不是必需的，但为了完整性而包含在内。

- **前端 (`src/app/issue/page.tsx`):** 一个单一的 [React](https://www.corbado.com/blog/react-passkeys)
  页面，允许用户输入他们的数据以申请凭证。它会调用我们的后端 API 来启动签发过程。
- **后端 API 路由 (`src/app/api/issue/...`):** 一组实现 OpenID4VCI 协议的服务器端端点。
    - `/.well-known/openid-credential-issuer`: 一个公共元数据端点。这是钱包将检查的第一个 URL，用以发现签发者的能力，包括其授权服务器、令牌端点、凭证端点以及它提供的凭证类型。
    - `/.well-known/openid-configuration`: 一个标准的 OpenID
      Connect 发现端点。虽然与上面的端点密切相关，但此端点提供更广泛的 OIDC 相关配置，并且通常是与标准 OpenID 客户端互操作所必需的。
    - `/.well-known/did.json`: 我们签发者的 DID 文档。当使用 `did:web`
      方法时，此文件用于发布签发者的公钥，验证者可以用它来验证其签发的凭证签名。
    - `authorize/route.ts`: 创建一个 `pre-authorized_code` 和一个凭证邀约。
    - `token/route.ts`: 将 `pre-authorized_code` 交换为访问令牌。
    - `credential/route.ts`: 签发最终的、经过加密签名的 JWT-VC。
    - `schemas/pid/route.ts`: 暴露 PID 凭证的 JSON
      schema。这允许凭证的任何消费者了解其结构和数据类型。
- **库 (`src/lib/`):**
    - `database.ts`: 管理所有数据库交互，例如存储授权码和签发者密钥。
    - `crypto.ts`: 处理所有加密操作，包括密钥生成和 JWT 签名。

下图展示了签发流程：

![数字凭证签发流程](https://s3.eu-central-1.amazonaws.com/corbado-cloud-staging-website-assets/Mermaid_Chart_Create_complex_visual_diagrams_with_text_A_smarter_way_of_creating_diagrams_2025_07_29_145228_d28fd13731.svg)

## 4. 构建签发者

现在我们对标准、协议和架构有了扎实的理解，可以开始构建我们的签发者了。

> **跟着做或直接使用最终代码**
>
> 我们现在将逐步介绍设置和代码实现。如果你想直接跳到最终成品，可以从我们的 GitHub 仓库克隆完整的项目并在本地运行。
>
> ```bash
> git clone https://github.com/corbado/digital-credentials-example.git
> ```

### 4.1 设置项目

首先，我们将初始化一个新的 Next.js 项目，安装必要的依赖项，并启动我们的数据库。

#### 4.1.1 初始化 Next.js 应用

打开你的终端，导航到你想要创建项目的目录，并运行以下命令。我们在这个项目中使用 App
Router、TypeScript 和 Tailwind CSS。

```bash
npx create-next-app@latest . --ts --eslint --tailwind --app --src-dir --import-alias "@/*" --use-npm
```

此命令会在你的当前目录中搭建一个新的 Next.js 应用程序框架。

#### 4.1.2 安装依赖项

接下来，我们需要安装处理 JWT、数据库连接和 UUID 生成的库。

```bash
npm install jose mysql2 uuid @types/uuid
```

此命令安装了：

- `jose`: 用于签署和验证 JSON Web Tokens (JWTs)。
- `mysql2`: 我们数据库的 [MySQL](https://www.corbado.com/blog/passkey-webauthn-database-guide) 客户端。
- `uuid`: 用于生成唯一的挑战字符串。
- `@types/uuid`: `uuid` 库的 TypeScript 类型。

#### 4.1.3 启动数据库

我们的后端需要一个 [MySQL](https://www.corbado.com/blog/passkey-webauthn-database-guide)
数据库来存储授权码、签发会话和签发者密钥。我们已经包含了一个 `docker-compose.yml`
文件来简化这个过程。

如果你已经克隆了仓库，只需运行 `docker-compose up -d`。如果你是从头开始构建，创建一个名为
`docker-compose.yml` 的文件，内容如下：

```yaml
services:
    mysql:
        image: mysql:8.0
        restart: always
        environment:
            MYSQL_ROOT_PASSWORD: rootpassword
            MYSQL_DATABASE: digital_credentials
            MYSQL_USER: app_user
            MYSQL_PASSWORD: app_password
        ports:
            - "3306:3306"
        volumes:
            - mysql_data:/var/lib/mysql
            - ./sql/init.sql:/docker-entrypoint-initdb.d/init.sql
        healthcheck:
            test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
            timeout: 20s
            retries: 10

volumes:
    mysql_data:
```

这个 Docker Compose 设置还需要一个 SQL 初始化脚本。创建一个名为 `sql`
的目录，并在其中创建一个名为 `init.sql`
的文件，内容如下，用于为验证者和签发者设置必要的表：

```sql
-- Create database if not exists
CREATE DATABASE IF NOT EXISTS digital_credentials;
USE digital_credentials;

-- Table for storing challenges
CREATE TABLE IF NOT EXISTS challenges (
    id VARCHAR(36) PRIMARY KEY,
    challenge VARCHAR(255) NOT NULL UNIQUE,
    expires_at TIMESTAMP NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    used BOOLEAN DEFAULT FALSE,
    INDEX idx_challenge (challenge),
    INDEX idx_expires_at (expires_at)
);

-- Table for storing verification sessions
CREATE TABLE IF NOT EXISTS verification_sessions (
    id VARCHAR(36) PRIMARY KEY,
    challenge_id VARCHAR(36),
    status ENUM('pending', 'verified', 'failed', 'expired') DEFAULT 'pending',
    presentation_data JSON,
    verified_at TIMESTAMP NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    FOREIGN KEY (challenge_id) REFERENCES challenges(id) ON DELETE CASCADE,
    INDEX idx_challenge_id (challenge_id),
    INDEX idx_status (status)
);

-- Table for storing verified credentials data (optional)
CREATE TABLE IF NOT EXISTS verified_credentials (
    id VARCHAR(36) PRIMARY KEY,
    session_id VARCHAR(36),
    credential_type VARCHAR(255),
    issuer VARCHAR(255),
    subject VARCHAR(255),
    claims JSON,
    verified_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (session_id) REFERENCES verification_sessions(id) ON DELETE CASCADE,
    INDEX idx_session_id (session_id),
    INDEX idx_credential_type (credential_type)
);

-- ISSUER TABLES

-- Table for storing authorization codes in OpenID4VCI flow
CREATE TABLE IF NOT EXISTS authorization_codes (
    id VARCHAR(36) PRIMARY KEY,
    code VARCHAR(255) NOT NULL UNIQUE,
    client_id VARCHAR(255),
    scope VARCHAR(255),
    code_challenge VARCHAR(255),
    code_challenge_method VARCHAR(50),
    redirect_uri TEXT,
    user_pin VARCHAR(10),
    expires_at TIMESTAMP NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    used BOOLEAN DEFAULT FALSE,
    INDEX idx_code (code),
    INDEX idx_expires_at (expires_at)
);

-- Table for storing issuance sessions
CREATE TABLE IF NOT EXISTS issuance_sessions (
    id VARCHAR(36) PRIMARY KEY,
    authorization_code_id VARCHAR(36),
    access_token VARCHAR(255),
    token_type VARCHAR(50) DEFAULT 'Bearer',
    expires_in INT DEFAULT 3600,
    c_nonce VARCHAR(255),
    c_nonce_expires_at TIMESTAMP,
    status ENUM('pending', 'authorized', 'credential_issued', 'expired', 'failed') DEFAULT 'pending',
    user_data JSON,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    FOREIGN KEY (authorization_code_id) REFERENCES authorization_codes(id) ON DELETE CASCADE,
    INDEX idx_access_token (access_token),
    INDEX idx_c_nonce (c_nonce),
    INDEX idx_status (status)
);

-- Table for storing issued credentials
CREATE TABLE IF NOT EXISTS issued_credentials (
    id VARCHAR(36) PRIMARY KEY,
    session_id VARCHAR(36),
    credential_id VARCHAR(255),
    credential_type VARCHAR(255) DEFAULT 'jwt_vc',
    doctype VARCHAR(255) DEFAULT 'eu.europa.ec.eudi.pid.1',
    credential_data LONGTEXT, -- Base64 encoded mDoc
    credential_claims JSON,
    issuer_did VARCHAR(255),
    subject_id VARCHAR(255),
    issued_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    expires_at TIMESTAMP,
    revoked BOOLEAN DEFAULT FALSE,
    revoked_at TIMESTAMP NULL,
    FOREIGN KEY (session_id) REFERENCES issuance_sessions(id) ON DELETE CASCADE,
    INDEX idx_credential_id (credential_id),
    INDEX idx_session_id (session_id),
    INDEX idx_doctype (doctype),
    INDEX idx_subject_id (subject_id),
    INDEX idx_issued_at (issued_at)
);

-- Table for storing issuer keys (simplified for demo)
CREATE TABLE IF NOT EXISTS issuer_keys (
    id VARCHAR(36) PRIMARY KEY,
    key_id VARCHAR(255) NOT NULL UNIQUE,
    key_type VARCHAR(50) NOT NULL, -- 'EC', 'RSA'
    algorithm VARCHAR(50) NOT NULL, -- 'ES256', 'RS256', etc.
    public_key TEXT NOT NULL, -- JWK format
    private_key TEXT NOT NULL, -- JWK format (encrypted in production)
    is_active BOOLEAN DEFAULT TRUE,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    INDEX idx_key_id (key_id),
    INDEX idx_is_active (is_active)
);
```

当这两个文件都准备好后，在项目根目录的终端中运行：

```bash
docker-compose up -d
```

此命令将在后台启动一个 MySQL 容器，供我们的应用程序使用。

### 4.2 实现共享库

在构建 API 端点之前，我们先来创建处理核心业务逻辑的共享库。这种方法可以保持我们的 API 路由清晰，专注于处理 HTTP 请求，而将复杂的工作委托给这些模块。

#### 4.2.1 数据库库 (`src/lib/database.ts`)

此文件是所有数据库交互的唯一来源。它使用 `mysql2`
库连接到我们的 MySQL 容器，并提供了一组导出的函数，用于创建、读取和更新我们表中的记录。这个抽象层使我们的代码更模块化，更易于维护。

创建文件 `src/lib/database.ts`，内容如下：

```typescript
// src/lib/database.ts
import mysql from "mysql2/promise";

// 数据库连接配置
const dbConfig = {
    host: process.env.DATABASE_HOST || "localhost",
    port: parseInt(process.env.DATABASE_PORT || "3306"),
    user: process.env.DATABASE_USER || "app_user",
    password: process.env.DATABASE_PASSWORD || "app_password",
    database: process.env.DATABASE_NAME || "digital_credentials",
    timezone: "+00:00",
};

let connection: mysql.Connection | null = null;

export async function getConnection(): Promise<mysql.Connection> {
    if (!connection) {
        connection = await mysql.createConnection(dbConfig);
    }
    return connection;
}

// 每个表的数据访问对象 (DAO) 函数
// ... (例如, createChallenge, getChallenge, createAuthorizationCode, 等)
```

> **注意：**
> 为简洁起见，此处省略了 DAO 函数的完整列表。你可以在[项目仓库](https://github.com/corbado/digital-credentials-example/blob/main/src/lib/database.ts)中找到完整的代码。该文件包括管理挑战、验证会话、授权码、签发会话和签发者密钥的函数。

#### 4.2.2 加密库 (`src/lib/crypto.ts`)

此文件处理所有对安全至关重要的加密操作。它使用 `jose` 库来生成密钥对和签署 JSON Web Tokens
(JWTs)。

**密钥生成** `generateIssuerKeyPair`
函数创建一个新的椭圆曲线密钥对，将用于签署凭证。公钥以 JSON Web Key
(JWK) 格式导出，以便可以发布在我们的 `did.json` 文档中。

```typescript
// src/lib/crypto.ts
import { generateKeyPair, exportJWK, SignJWT } from "jose";

export async function generateIssuerKeyPair(keyId: string, issuerDid: string) {
    const { publicKey, privateKey } = await generateKeyPair("ES256", {
        crv: "P-256",
        extractable: true,
    });

    const publicKeyJWK = await exportJWK(publicKey);
    publicKeyJWK.kid = keyId; // 分配一个唯一的密钥 ID

    // ... (私钥导出和其他设置)

    return { publicKey, privateKey, publicKeyJWK /* ... */ };
}
```

**JWT 凭证创建** `createJWTVerifiableCredential`
函数是签发过程的核心。它接收用户的声明、签发者的密钥对和其他元数据，并用它们来创建一个签名的 JWT-VC。

```typescript
// src/lib/crypto.ts

export async function createJWTVerifiableCredential(
    claims: MDocClaims,
    issuerKeyPair: IssuerKeyPair,
    subjectId: string,
    audience: string,
): Promise<string> {
    const now = Math.floor(Date.now() / 1000);
    const oneYear = 365 * 24 * 60 * 60;

    const vcPayload = {
        // 签发者的 DID
        iss: issuerKeyPair.issuerDid,
        // 主体 (持有者) 的 DID
        sub: subjectId,
        // 凭证签发时间 (iat) 和过期时间 (exp)
        iat: now,
        exp: now + oneYear,
        // 可验证凭证数据模型
        vc: {
            "@context": [
                "https://www.w3.org/2018/credentials/v1",
                "https://europa.eu/eudi/pid/v1",
            ],
            type: ["VerifiableCredential", "eu.europa.ec.eudi.pid.1"],
            issuer: issuerKeyPair.issuerDid,
            issuanceDate: new Date(now * 1000).toISOString(),
            credentialSubject: {
                id: subjectId,
                ...claims,
            },
        },
    };

    // 使用签发者的私钥对 payload 进行签名
    return await new SignJWT(vcPayload)
        .setProtectedHeader({
            alg: issuerKeyPair.algorithm,
            kid: issuerKeyPair.keyId,
            typ: "JWT",
        })
        .sign(issuerKeyPair.privateKey);
}
```

此函数根据 W3C 可验证凭证数据模型构建 JWT
payload，并用签发者的私钥对其进行签名，从而生成一个安全且可验证的凭证。

### 4.2 Next.js 应用的架构概览

我们的 Next.js 应用程序结构清晰，将前端和后端分离开来，尽管它们属于同一个项目。这是通过利用 App
Router 同时管理 UI 页面和 API 端点来实现的。

- **前端 (`src/app/issue/page.tsx`):** 一个单一的 [React](https://www.corbado.com/blog/react-passkeys)
  页面组件，定义了 `/issue` 路由的 UI。它处理用户输入并与我们的后端 API 通信。

- **后端 API 路由 (`src/app/api/...`):**
    - **发现 (`.well-known/.../route.ts`):**
      这些路由暴露了公共元数据端点，允许钱包和其他客户端发现签发者的能力和公钥。
    - **签发 (`issue/.../route.ts`):**
      这些端点实现了核心的 OpenID4VCI 逻辑，包括创建凭证邀约、签发令牌和签署最终凭证。
    - **Schema (`schemas/pid/route.ts`):** 此路由提供凭证的 JSON schema，定义了其结构。

- **库 (`src/lib/`):** 此目录包含在整个后端共享的可重用逻辑。
    - `database.ts`: 管理所有数据库交互，抽象掉 SQL 查询。
    - `crypto.ts`: 处理所有加密操作，例如密钥生成和 JWT 签名。

这种清晰的分离使应用程序模块化且易于维护。

**注意：** `generateIssuerDid()` 函数必须返回一个与你的签发者域匹配的有效
`did:web`。部署时，`.well-known/did.json`
必须通过 HTTPS 在该域上提供服务，以便验证者验证凭证。

![Next.js 应用架构概览](https://s3.eu-central-1.amazonaws.com/corbado-cloud-staging-website-assets/Mermaid_Chart_Create_complex_visual_diagrams_with_text_A_smarter_way_of_creating_diagrams_2025_07_29_151549_6a0aca6477.svg)

### 4.3 构建前端

我们的前端是一个单一的 React 页面，提供一个简单的表单供用户申请新的数字凭证。其职责是：

- 捕获用户数据（姓名、出生日期等）。
- 将此数据发送到我们的后端以创建凭证邀约。
- 显示生成的二维码和 PIN 码，供用户用钱包扫描。

核心逻辑在 `handleSubmit` 函数中处理，该函数在用户提交表单时触发。

```typescript
// src/app/issue/page.tsx

const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setLoading(true);
    setError(null);
    setCredentialOffer(null);

    try {
        // 1. 验证必填字段
        if (!userData.given_name || !userData.family_name || !userData.birth_date) {
            throw new Error("请填写所有必填字段");
        }

        // 2. 从后端请求凭证邀约
        const response = await fetch("/api/issue/authorize", {
            method: "POST",
            headers: {
                "Content-Type": "application/json",
            },
            body: JSON.stringify({
                user_data: userData,
            }),
        });

        if (!response.ok) {
            const errorData = await response.json();
            throw new Error(errorData.error_description || "创建凭证邀约失败");
        }

        // 3. 在 state 中设置凭证邀约以显示二维码
        const result = await response.json();
        setCredentialOffer(result);
    } catch (err) {
        const errorMessage = (err as Error).message || "发生未知错误";
        setError(errorMessage);
    } finally {
        setLoading(false);
    }
};
```

此函数执行三个关键操作：

1. **验证表单数据**以确保所有必填字段都已填写。
2. 向我们的 `/api/issue/authorize` 端点**发送一个 `POST` 请求**，附带用户数据。
3. 用从后端收到的凭证邀约**更新组件的 state**，这将触发 UI 显示二维码和交易码。

文件的其余部分包含用于渲染表单和二维码显示的标准 React 代码。你可以在[项目仓库](https://github.com/corbado/digital-credentials-example/blob/main/src/app/issue/page.tsx)中查看完整文件。

### 4.4 设置环境和发现机制

在构建后端 API 之前，我们需要配置我们的环境并设置发现端点。这些 `.well-known`
文件对于钱包找到我们的签发者并了解如何与之交互至关重要。

#### 4.4.1 创建环境文件

在你的项目根目录中创建一个名为 `.env.local`
的文件，并添加以下行。此 URL 必须是可公开访问的，以便移动钱包能够访问它。对于本地开发，你可以使用像
[ngrok](https://www.corbado.com/blog/multi-device-passkey-login-corbado-ngrok) 这样的隧道服务来暴露你的
`localhost`。

```
NEXT_PUBLIC_BASE_URL=http://localhost:3000
```

#### 4.4.2 实现发现端点

钱包通过查询标准的 `.well-known` URL 来发现签发者的能力。我们需要创建三个这样的端点。

**1. 签发者元数据 (`/.well-known/openid-credential-issuer`)**

这是 OpenID4VCI 的主要发现文件。它告诉钱包关于签发者的一切信息，包括其端点、提供的凭证类型以及支持的加密算法。

创建文件 `src/app/.well-known/openid-credential-issuer/route.ts`：

```typescript
// src/app/.well-known/openid-credential-issuer/route.ts
import { NextResponse } from "next/server";

export async function GET() {
    const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3000";

    const issuerMetadata = {
        // 签发者的唯一标识符。
        issuer: baseUrl,
        // 授权服务器的 URL。为简单起见，我们的签发者本身就是其授权服务器。
        authorization_servers: [baseUrl],
        // 凭证签发者的 URL。
        credential_issuer: baseUrl,
        // 钱包将 POST 请求以接收实际凭证的端点。
        credential_endpoint: `${baseUrl}/api/issue/credential`,
        // 钱包用于交换授权码以获取访问令牌的端点。
        token_endpoint: `${baseUrl}/api/issue/token`,
        // 授权流程的端点 (在我们的预授权流程中未使用，但最好包含)。
        authorization_endpoint: `${baseUrl}/api/issue/authorize`,
        // 表明支持无需客户端身份验证的预授权码流程。
        pre_authorized_grant_anonymous_access_supported: true,
        // 关于签发者的人类可读信息。
        display: [
            {
                name: "Corbado Credentials Issuer",
                locale: "en-US",
            },
        ],
        // 此签发者可以签发的凭证类型列表。
        credential_configurations_supported: {
            "eu.europa.ec.eudi.pid.1": {
                // 凭证的格式 (例如, jwt_vc, mso_mdoc)。
                format: "jwt_vc",
                // 符合 ISO mDoc 标准的特定文档类型。
                doctype: "eu.europa.ec.eudi.pid.1",
                // 与此凭证类型关联的 OAuth 2.0 作用域。
                scope: "eu.europa.ec.eudi.pid.1",
                // 钱包可以用来证明其密钥所有权的方法。
                cryptographic_binding_methods_supported: ["jwk"],
                // 签发者为此凭证支持的签名算法。
                credential_signing_alg_values_supported: ["ES256"],
                // 钱包可以使用的所有权证明类型。
                proof_types_supported: {
                    jwt: {
                        proof_signing_alg_values_supported: ["ES256", "ES384", "ES512"],
                    },
                },
                // 凭证的显示属性。
                display: [
                    {
                        name: "Corbado Credential Issuer",
                        locale: "en-US",
                        logo: {
                            uri: `${baseUrl}/logo.png`,
                            alt_text: "EU Digital Identity",
                        },
                        background_color: "#003399",
                        text_color: "#FFFFFF",
                    },
                ],
                // 凭证中的声明 (属性) 列表。
                claims: {
                    "eu.europa.ec.eudi.pid.1": {
                        given_name: {
                            mandatory: true,
                            display: [{ name: "Given Name", locale: "en-US" }],
                        },
                        family_name: {
                            mandatory: true,
                            display: [{ name: "Family Name", locale: "en-US" }],
                        },
                        birth_date: {
                            mandatory: true,
                            display: [{ name: "Date of Birth", locale: "en-US" }],
                        },
                    },
                },
            },
        },
        // 令牌端点支持的身份验证方法。'none' 表示公共客户端。
        token_endpoint_auth_methods_supported: ["none"],
        // 支持的 PKCE 代码挑战方法。
        code_challenge_methods_supported: ["S256"],
        // 签发者支持的 OAuth 2.0 授权类型。
        grant_types_supported: [
            "authorization_code",
            "urn:ietf:params:oauth:grant-type:pre-authorized_code",
        ],
    };

    return NextResponse.json(issuerMetadata, {
        headers: {
            "Content-Type": "application/json",
            "Cache-Control": "no-cache, no-store, must-revalidate",
            Pragma: "no-cache",
            Expires: "0",
        },
    });
}
```

**2. OpenID 配置 (`/.well-known/openid-configuration`)**

这是一个标准的 OIDC 发现文档，提供了一套更广泛的配置细节。

创建文件 `src/app/.well-known/openid-configuration/route.ts`：

```typescript
// src/app/.well-known/openid-configuration/route.ts
import { NextResponse } from "next/server";

export async function GET() {
    const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3000";

    const openidConfiguration = {
        // 签发者的唯一标识符。
        credential_issuer: baseUrl,
        // 钱包将 POST 请求以接收实际凭证的端点。
        credential_endpoint: `${baseUrl}/api/issue/credential`,
        // 授权流程的端点。
        authorization_endpoint: `${baseUrl}/api/issue/authorize`,
        // 钱包用于交换授权码以获取访问令牌的端点。
        token_endpoint: `${baseUrl}/api/issue/token`,
        // 此签发者可以签发的凭证类型列表。
        credential_configurations_supported: {
            "eu.europa.ec.eudi.pid.1": {
                format: "jwt_vc",
                scope: "eu.europa.ec.eudi.pid.1",
                cryptographic_binding_methods_supported: ["jwk"],
                credential_signing_alg_values_supported: ["ES256", "ES384", "ES512"],
                proof_types_supported: {
                    jwt: {
                        proof_signing_alg_values_supported: ["ES256", "ES384", "ES512"],
                    },
                },
            },
        },
        // 签发者支持的 OAuth 2.0 授权类型。
        grant_types_supported: [
            "authorization_code",
            "urn:ietf:params:oauth:grant-type:pre-authorized_code",
        ],
        // 表明支持预授权码流程。
        pre_authorized_grant_anonymous_access_supported: true,
        // 支持的 PKCE 代码挑战方法。
        code_challenge_methods_supported: ["S256"],
        // 令牌端点支持的身份验证方法。
        token_endpoint_auth_methods_supported: ["none"],
        // 签发者支持的 OAuth 2.0 作用域。
        scopes_supported: ["eu.europa.ec.eudi.pid.1"],
    };

    return NextResponse.json(openidConfiguration, {
        headers: {
            "Content-Type": "application/json",
            "Cache-Control": "no-cache, no-store, must-revalidate",
            Pragma: "no-cache",
            Expires: "0",
        },
    });
}
```

**3. DID 文档 (`/.well-known/did.json`)**

此文件使用 `did:web` 方法发布签发者的公钥，允许任何人验证其签发的凭证签名。

创建文件 `src/app/.well-known/did.json/route.ts`：

```typescript
// src/app/.well-known/did.json/route.ts
import { NextResponse } from "next/server";
import { getActiveIssuerKey } from "../../../lib/database";
import { generateIssuerDid } from "../../../lib/crypto";

export async function GET() {
    const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3000";
    const issuerKey = await getActiveIssuerKey();

    if (!issuerKey) {
        return NextResponse.json(
            { error: "No active issuer key found" },
            { status: 404 },
        );
    }

    const publicKeyJWK = JSON.parse(issuerKey.public_key);
    const didId = generateIssuerDid();
    const didDocument = {
        // context 定义了文档中使用的词汇。
        "@context": [
            "https://www.w3.org/ns/did/v1",
            "https://w3id.org/security/suites/jws-2020/v1",
        ],
        // DID URI，是签发者的唯一标识符。
        id: didId,
        // DID 控制者，即控制该 DID 的实体。这里是签发者本身。
        controller: didId,
        // 可用于验证签发者签名的公钥列表。
        verificationMethod: [
            {
                // 密钥的唯一标识符，作用域为该 DID。
                id: `${didId}#${issuerKey.key_id}`,
                // 密钥的类型。
                type: "JsonWebKey2020",
                // 密钥控制者的 DID。
                controller: didId,
                // JWK 格式的公钥。
                publicKeyJwk: publicKeyJWK,
            },
        ],
        // 指定哪些密钥可用于身份验证 (证明对 DID 的控制权)。
        authentication: [`${didId}#${issuerKey.key_id}`],
        // 指定哪些密钥可用于创建可验证凭证。
        assertionMethod: [`${didId}#${issuerKey.key_id}`],
        // DID 主体提供的服务列表，例如签发者端点。
        service: [
            {
                id: `${didId}#openid-credential-issuer`,
                type: "OpenIDCredentialIssuer",
                serviceEndpoint: `${baseUrl}/.well-known/openid-credential-issuer`,
            },
        ],
    };

    return NextResponse.json(didDocument, {
        headers: {
            "Content-Type": "application/did+json",
            "Cache-Control": "no-cache, no-store, must-revalidate",
            Pragma: "no-cache",
            Expires: "0",
        },
    });
}
```

> **为什么不缓存？**
> 你会注意到所有这三个端点都返回了强制禁止缓存的头信息（`Cache-Control: no-cache`,
> `Pragma: no-cache`,
> `Expires: 0`）。这对于发现文档来说是一项关键的安全实践。签发者的配置可能会改变——例如，加密密钥可能会轮换。如果钱包或客户端缓存了旧版本的
> `did.json` 或 `openid-credential-issuer`
> 文件，它将无法验证新的凭证或与更新的端点交互。通过强制客户端在每次请求时获取最新副本，我们确保它们始终拥有最新的信息。

#### 4.4.3 实现凭证 Schema 端点

我们面向公众的[基础设施](https://www.corbado.com/passkeys-for-critical-infrastructure)的最后一部分是凭证 schema 端点。此路由提供一个 JSON
Schema，它正式定义了我们正在签发的 PID 凭证的结构、数据类型和约束。钱包和验证者可以使用此 schema 来验证凭证的内容。

创建文件 `src/app/api/schemas/pid/route.ts`，内容如下：

```typescript
// src/app/api/schemas/pid/route.ts
import { NextResponse } from "next/server";

export async function GET() {
    const schema = {
        $schema: "https://json-schema.org/draft/2020-12/schema",
        $id: "https://example.com/schemas/pid", // 替换为你的实际域
        title: "PID Credential",
        description:
            "A schema for a Verifiable Credential representing a Personal Identification Document (PID).",
        type: "object",
        properties: {
            credentialSubject: {
                type: "object",
                properties: {
                    given_name: { type: "string" },
                    family_name: { type: "string" },
                    birth_date: { type: "string", format: "date" },
                    // ... 凭证主体的其他属性
                },
                required: ["given_name", "family_name", "birth_date"],
            },
            // ... 可验证凭证的其他顶层属性
        },
    };

    return NextResponse.json(schema, {
        headers: {
            "Content-Type": "application/schema+json",
            "Access-Control-Allow-Origin": "*", // 允许跨域请求
        },
    });
}
```

> **注意：** PID 凭证的 JSON
> Schema 可能相当庞大和详细。为简洁起见，此处已截断了完整的 schema。你可以在[项目仓库](https://github.com/corbado/digital-credentials-example/blob/main/src/app/api/schemas/pid/route.ts)中找到完整文件。

### 4.5 构建后端端点

前端就位后，我们现在需要服务器端逻辑来处理 OpenID4VCI 流程。我们从前端调用的第一个端点开始：`/api/issue/authorize`。

#### 4.5.1 `/api/issue/authorize`: 创建凭证邀约

此端点负责接收用户数据，生成一个安全的一次性使用代码，并构建一个用户钱包可以理解的
`credential_offer`。

以下是核心逻辑：

```typescript
// src/app/api/issue/authorize/route.ts
import { NextRequest, NextResponse } from "next/server";
import { v4 as uuidv4 } from "uuid";
import { createAuthorizationCode } from "@/lib/database";

export async function POST(request: NextRequest) {
    try {
        const body = await request.json();
        const { user_data } = body;

        // 1. 验证用户数据
        if (
            !user_data ||
            !user_data.given_name ||
            !user_data.family_name ||
            !user_data.birth_date
        ) {
            return NextResponse.json({ error: "missing_user_data" }, { status: 400 });
        }

        // 2. 生成一个预授权码和一个 PIN
        const code = uuidv4();
        const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 分钟
        const txCode = Math.floor(1000 + Math.random() * 9000).toString(); // 4 位 PIN

        // 3. 存储代码和用户数据
        await createAuthorizationCode(uuidv4(), code, expiresAt);
        // 注意：这仅为演示目的使用内存存储。
        // 在生产环境中，应将数据安全地持久化到数据库中，并设置适当的过期时间。
        if (!(global as any).userDataStore) (global as any).userDataStore = new Map();
        (global as any).userDataStore.set(code, user_data);
        if (!(global as any).txCodeStore) (global as any).txCodeStore = new Map();
        (global as any).txCodeStore.set(code, txCode);

        // 4. 创建凭证邀约对象
        const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3000";
        const credentialOffer = {
            // 签发者的标识符，即其基础 URL。
            credential_issuer: baseUrl,
            // 签发者提供的凭证类型数组。
            credential_configuration_ids: ["eu.europa.ec.eudi.pid.1"],
            // 指定钱包可以使用的授权类型。
            grants: {
                // 我们正在使用预授权码流程。
                "urn:ietf:params:oauth:grant-type:pre-authorized_code": {
                    // 钱包将用于交换令牌的一次性代码。
                    "pre-authorized_code": code,
                    // 表明用户必须输入 PIN (tx_code) 来兑换代码。
                    user_pin_required: true,
                },
            },
        };

        // 5. 创建完整的凭证邀约 URI (钱包的深层链接)
        const credentialOfferUri = `openid-credential-offer://?credential_offer=${encodeURIComponent(
            JSON.stringify(credentialOffer),
        )}`;

        // 对前端的最终响应。
        return NextResponse.json({
            // 用于二维码的深层链接。
            credential_offer_uri: credentialOfferUri,
            // 原始的预授权码，用于显示或手动输入。
            pre_authorized_code: code,
            // 用户必须在其钱包中输入的 4 位 PIN。
            tx_code: txCode,
        });
    } catch (error) {
        console.error("Authorization error:", error);
        return NextResponse.json({ error: "server_error" }, { status: 500 });
    }
}
```

此端点的关键步骤：

1. **验证数据：** 首先确保所需的用户数据存在。
2. **生成代码：** 创建一个唯一的 `pre-authorized_code` (一个 UUID) 和一个 4 位数的
   `tx_code` (PIN)，以增加一层安全性。
3. **持久化数据：** `pre-authorized_code`
   被存储在数据库中，并设置了短暂的过期时间。用户数据和 PIN 存储在内存中，与代码关联。
4. **构建邀约：** 根据 OpenID4VCI 规范构建 `credential_offer`
   对象。此对象告诉钱包签发者的位置、它提供什么凭证以及获取凭证所需的代码。
5. **返回 URI：** 最后，它创建一个深层链接 URI
   (`openid-credential-offer://...`) 并将其返回给前端，同时返回 `tx_code` 供用户查看。

#### 4.5.2 `/api/issue/token`: 用代码交换令牌

一旦用户扫描了二维码并输入了他们的 PIN，钱包就会向此端点发出 `POST` 请求。它的工作是验证
`pre-authorized_code` 和 `user_pin` (PIN)，如果它们有效，则签发一个短暂的访问令牌。

创建文件 `src/app/api/issue/token/route.ts`，内容如下：

```typescript
// src/app/api/issue/token/route.ts
import { NextRequest, NextResponse } from "next/server";
import { v4 as uuidv4 } from "uuid";
import {
    getAuthorizationCode,
    markAuthorizationCodeAsUsed,
    createIssuanceSession,
} from "@/lib/database";

export async function POST(request: NextRequest) {
    try {
        const formData = await request.formData();
        const grant_type = formData.get("grant_type") as string;
        const code = formData.get("pre-authorized_code") as string;
        const user_pin = formData.get("user_pin") as string;

        // 1. 验证授权类型
        if (grant_type !== "urn:ietf:params:oauth:grant-type:pre-authorized_code") {
            return NextResponse.json(
                { error: "unsupported_grant_type" },
                { status: 400 },
            );
        }

        // 2. 验证预授权码
        const authCode = await getAuthorizationCode(code);
        if (!authCode) {
            return NextResponse.json(
                {
                    error: "invalid_grant",
                    error_description: "Invalid or expired code",
                },
                { status: 400 },
            );
        }

        // 3. 验证 PIN (tx_code)
        const expectedTxCode = (global as any).txCodeStore?.get(code);
        if (expectedTxCode !== user_pin) {
            return NextResponse.json(
                { error: "invalid_grant", error_description: "Invalid PIN" },
                { status: 400 },
            );
        }

        // 4. 生成访问令牌和 c_nonce
        const accessToken = uuidv4();
        const cNonce = uuidv4();
        const cNonceExpiresAt = new Date(Date.now() + 5 * 60 * 1000); // 5 分钟

        // 5. 创建一个新的签发会话
        const userData = (global as any).userDataStore?.get(code);
        await createIssuanceSession(
            uuidv4(),
            authCode.id,
            accessToken,
            cNonce,
            cNonceExpiresAt,
            userData,
        );

        // 6. 将代码标记为已使用并清理临时数据
        await markAuthorizationCodeAsUsed(code);
        (global as any).txCodeStore?.delete(code);
        (global as any).userDataStore?.delete(code);

        // 7. 返回访问令牌响应
        return NextResponse.json({
            access_token: accessToken,
            token_type: "Bearer",
            expires_in: 3600, // 1 小时
            c_nonce: cNonce,
            c_nonce_expires_in: 300, // 5 分钟
        });
    } catch (error) {
        console.error("Token endpoint error:", error);
        return NextResponse.json({ error: "server_error" }, { status: 500 });
    }
}
```

此端点的关键步骤：

1. **验证授权类型：** 确保钱包使用的是正确的 `pre-authorized_code` 授权类型。
2. **验证代码：** 检查 `pre-authorized_code`
   是否存在于数据库中，是否未过期，并且之前未使用过。
3. **验证 PIN：** 将来自钱包的 `user_pin` 与我们之前存储的 `tx_code`
   进行比较，以确保用户授权了该交易。
4. **生成令牌：** 创建一个安全的 `access_token` 和一个 `c_nonce` (credential
   nonce)，这是一个一次性使用的值，以防止对凭证端点的重放攻击。
5. **创建会话：** 在数据库中创建一个新的 `issuance_sessions`
   记录，将访问令牌与用户数据关联起来。
6. **将代码标记为已使用：** 为防止同一个邀约被使用两次，它将 `pre-authorized_code`
   标记为已使用。
7. **返回令牌：** 将 `access_token` 和 `c_nonce` 返回给钱包。

#### 4.5.3 `/api/issue/credential`: 签发已签名的凭证

这是最后一个也是最重要的端点。钱包使用它从 `/token`
端点收到的访问令牌向此路由发出一个经过身份验证的 `POST`
请求。此端点的工作是执行最终验证，创建经过加密签名的凭证，并将其返回给钱包。

创建文件 `src/app/api/issue/credential/route.ts`，内容如下：

```typescript
// src/app/api/issue/credential/route.ts
import { NextRequest, NextResponse } from "next/server";
import { v4 as uuidv4 } from "uuid";
import {
    getIssuanceSessionByToken,
    updateIssuanceSession,
    createIssuedCredential,
    getActiveIssuerKey,
} from "@/lib/database";
import {
    createJWTVerifiableCredential,
    importIssuerKeyPair,
    generateIssuerDid,
} from "@/lib/crypto";

export async function POST(request: NextRequest) {
    try {
        // 1. 验证 Bearer 令牌
        const authHeader = request.headers.get("authorization");
        const accessToken = authHeader?.substring(7);
        const session = await getIssuanceSessionByToken(accessToken);

        if (!session) {
            return NextResponse.json({ error: "invalid_token" }, { status: 401 });
        }

        // 2. 从会话中获取用户数据
        const userData = session.user_data;
        if (!userData) {
            return NextResponse.json({ error: "missing_user_data" }, { status: 400 });
        }

        // 3. 获取活动的签发者密钥
        const issuerKey = await getActiveIssuerKey();
        if (!issuerKey) {
            // 在实际应用中，你会有更强大的密钥管理系统。
            // 对于此演示，如果密钥不存在，我们可以即时生成一个。
            // 此部分为简洁起见省略，但在仓库中有。
            return NextResponse.json(
                {
                    error: "server_error",
                    error_description: "Failed to get issuer key",
                },
                { status: 500 },
            );
        }

        // 4. 创建 JWT-VC
        const issuerDid = generateIssuerDid();
        const keyPair = await importIssuerKeyPair(
            issuerKey.key_id,
            issuerKey.public_key,
            issuerKey.private_key,
            issuerDid,
        );
        const subjectId = `did:example:${uuidv4()}`;
        const credentialData = await createJWTVerifiableCredential(
            userData,
            keyPair,
            subjectId,
            process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3000",
        );

        // 5. 在数据库中存储已签发的凭证
        await createIssuedCredential(/* ... credential details ... */);
        await updateIssuanceSession(session.id, "credential_issued");

        // 6. 返回已签名的凭证
        return NextResponse.json({
            format: "jwt_vc",
            credential: credentialData,
            c_nonce: uuidv4(), // 用于后续请求的新 nonce
            c_nonce_expires_in: 300,
        });
    } catch (error) {
        console.error("Credential endpoint error:", error);
        return NextResponse.json({ error: "server_error" }, { status: 500 });
    }
}
```

此端点的关键步骤：

1. **验证令牌：** 检查 `Authorization` 头中是否存在有效的 `Bearer`
   令牌，并用它来查找活动的签发会话。
2. **检索用户数据：** 检索用户的声明数据，这些数据在创建令牌时已存储在会话中。
3. **加载签发者密钥：**
   从数据库中加载签发者的活动签名密钥。在实际场景中，这将由安全的密钥管理系统管理。
4. **创建凭证：** 调用我们 `src/lib/crypto.ts` 中的 `createJWTVerifiableCredential`
   辅助函数来构建并签署 JWT-VC。
5. **记录签发：** 在数据库中保存已签发凭证的记录，以用于审计和撤销目的。
6. **返回凭证：** 将已签名的凭证以 JSON 响应的形式返回给钱包。钱包随后负责安全地存储它。

## 5. 运行签发者和后续步骤

现在你已经拥有一个完整的、端到端的数字凭证签发者实现。以下是如何在本地运行它，以及将其从概念验证推进到生产就绪应用程序需要考虑的事项。

### 5.1 如何运行示例

1. **克隆仓库：**

    ```bash
    git clone https://github.com/corbado/digital-credentials-example.git
    cd digital-credentials-example
    ```

2. **安装依赖项：**

    ```bash
    npm install
    ```

3. **启动数据库：** 确保 Docker 正在运行，然后启动 MySQL 容器：

    ```bash
    docker-compose up -d
    ```

4. **配置环境并运行隧道：**
   这是本地测试最关键的一步。由于你的移动钱包需要通过互联网连接到你的开发机器，你必须用一个公共的 HTTPS
   URL 暴露你的本地服务器。我们将为此使用 `ngrok`。

    a. **启动 ngrok：**

    ```bash
    ngrok http 3000
    ```

    b. 从 [ngrok](https://www.corbado.com/blog/multi-device-passkey-login-corbado-ngrok) 输出中**复制 HTTPS URL**
    (例如, `https://random-string.ngrok.io`)。c. **创建一个 `.env.local`
    文件**并设置该 URL：

    ```
    NEXT_PUBLIC_BASE_URL=https://<your-ngrok-url>
    ```

5. **运行应用程序：**

    ```bash
    npm run dev
    ```

    在浏览器中打开
    `http://localhost:3000/issue`。你现在可以填写表单，生成的二维码将正确指向你的公共
    [ngrok](https://www.corbado.com/blog/multi-device-passkey-login-corbado-ngrok)
    URL，从而允许你的移动钱包连接并接收凭证。

### 5.2 HTTPS 和 `ngrok` 的重要性

数字凭证协议的设计将安全性作为首要任务。因此，钱包几乎总是拒绝通过不安全的 (`http://`) 连接来连接到签发者。整个过程依赖于安全的
**HTTPS** 连接，这由 **SSL 证书**启用。

像 `ngrok` 这样的隧道服务通过创建一个安全的、面向公众的 HTTPS
URL（带有有效的 SSL 证书）并将所有流量转发到你的本地开发服务器，从而同时解决了这两个问题。钱包需要 HTTPS，并会拒绝连接到不安全的 (`http://`) 端点。这是测试任何需要与移动设备或外部 webhook 交互的 Web 服务时必不可少的工具。

### 5.3 本教程未涵盖的内容

此示例有意专注于核心签发流程，以便于理解。以下主题被认为超出了范围：

- **生产级安全：**
  此签发者仅用于教育目的。生产系统需要一个安全的密钥管理系统 (KMS) 而不是将密钥存储在数据库中，还需要稳健的错误处理、速率限制和全面的审计日志。
- **凭证撤销：** 本指南未实现撤销已签发凭证的机制。虽然 schema 中包含一个 `revoked`
  标志以备将来使用，但此处未提供任何撤销逻辑。
- **授权码流程：** 我们只专注于 `pre-authorized_code` 流程。完整实现 `authorization_code`
  流程将需要用户同意屏幕和更复杂的 [OAuth 2.0](https://www.corbado.com/glossary/oauth2) 逻辑。
- **用户管理：**
  本指南不包括签发者本身的任何用户身份验证或管理。它假设用户已经过身份验证并被授权接收凭证。

## 6. 结论

就这样！通过几页代码，我们现在拥有了一个完整的、端到端的数字凭证签发者，它能够：

1. 提供一个用户友好的前端来申请凭证。
2. 实现完整的 OpenID4VCI `pre-authorized_code` 流程。
3. 为钱包互操作性暴露所有必要的发现端点。
4. 生成并签署一个安全的、符合标准的 JWT-可验证凭证。

虽然本指南提供了一个坚实的基础，但一个生产就绪的签发者还需要额外的功能，如强大的密钥管理、使用持久化存储代替内存存储、凭证撤销以及全面的安全加固。钱包的兼容性也各不相同；推荐使用 Sphereon
[Wallet](https://www.corbado.com/blog/digital-wallet-assurance)
进行测试，但其他钱包可能不支持此处实现的预授权流程。然而，核心构建块和交互流程将保持不变。通过遵循这些模式，你可以为任何类型的数字凭证构建一个安全且可互操作的签发者。

## 7. 资源

以下是本教程中使用或引用的一些关键资源、规范和工具：

- **项目仓库：**
    - [GitHub 上的完整源代码](https://github.com/corbado/digital-credentials-example)

- **关键规范：**
    - [OpenID for Verifiable Credential Issuance (OpenID4VCI)](https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html): 核心签发协议。
    - [W3C Verifiable Credentials Data Model](https://www.w3.org/TR/vc-data-model/):
      VC 的基础标准。
    - [The `did:web` Method](https://w3c-ccg.github.io/did-method-web/): 我们签发者公钥使用的 DID 方法。

- **工具：**
    - [Sphereon Wallet](https://sphereon.com/wallet/): 本指南中使用的测试钱包。
    - ngrok: 用于为你的本地开发环境创建安全隧道。

- **库：**
    - Next.js: 用于构建前端和后端的 React 框架。
    - [jose](https://github.com/panva/jose): 用于创建和签署 JSON Web Tokens (JWTs)。
    - [mysql2](https://github.com/sidorares/node-mysql2): [Node.js](https://www.corbado.com/blog/nodejs-passkeys)
      的 MySQL 客户端。
