学习如何使用 OpenID4VCI 协议构建一个 W3C 可验证凭证签发者。这份分步指南将向你展示如何创建一个 Next.js 应用程序,用于签发与数字钱包兼容的、经过加密签名的凭证。
Amine
Created: August 20, 2025
Updated: August 21, 2025
See the original blog version in English here.
数字凭证是一种以安全、保护隐私的方式证明身份和声明的强大工具。但用户最初是如何获得这些凭证的呢?这时,签发者 (Issuer) 的角色就至关重要了。签发者 是一个受信任的实体,例如政府机构、大学或银行,负责创建并向用户分发经过数字签名的凭证。
本指南提供了一个全面、分步的教程,教我们如何构建一个数字凭证签发者。我们将重点关注 OpenID for Verifiable Credential Issuance (OpenID4VCI) 协议,这是一个现代标准,定义了用户如何从签发者处获取凭证,并将其安全地存储在他们的数字钱包中。
最终,我们将构建一个功能齐全的 Next.js 应用程序,它能够:
在继续之前,我们有必要澄清两个相关但不同的概念之间的区别:
数字凭证 (Digital Credentials - 通用术语): 这是一个宽泛的类别,涵盖任何数字形式的凭证、证书或证明。这些可以包括简单的数字证书、基础的数字徽章,或任何以电子方式存储的、可能具备也可能不具备加密安全功能的凭证。
可验证凭证 (Verifiable Credentials, VCs - W3C 标准): 这是一种遵循 W3C 可验证凭证数据模型标准的特定类型的数字凭证。可验证凭证是经过加密签名、防篡改且尊重隐私的凭证,可以被独立验证。它们包含特定的技术要求,例如:
在本指南中,我们专门构建一个遵循 W3C 标准的可验证凭证签发者,而不仅仅是任何数字凭证系统。我们使用的 OpenID4VCI 协议是专为签发可验证凭证而设计的,而我们将实现的 JWT-VC 格式是 W3C 兼容的可验证凭证格式。
数字凭证背后的奥秘在于一个简单而强大的“信任三角”模型,涉及三个关键角色:
签发流程是这个生态系统的第一步。签发者验证用户信息,并向他们提供凭证。一旦持有者在其钱包中拥有此凭证,他们就可以向验证者出示它来证明自己的身份或声明,从而完成这个三角关系。
以下是最终应用程序运行的快速演示:
第 1 步:用户数据输入 用户填写表单,提供个人信息以申请新凭证。
第 2 步:生成凭证邀约 应用程序生成一个安全的凭证邀约,并以二维码和预授权码的形式显示。
第 3 步:钱包交互 用户使用兼容的钱包(例如 Sphereon Wallet)扫描二维码,并输入 PIN 码以授权签发。
第 4 步:凭证已签发 钱包接收并存储新签发的数字凭证,以备将来使用。
在我们深入研究代码之前,让我们先了解需要掌握的基础知识和工具。本指南假设你对 Web 开发概念有基本的了解,但以下先决条件对于构建凭证签发者至关重要。
我们的签发者建立在一套开放标准之上,以确保钱包和签发服务之间的互操作性。在本教程中,我们将重点关注以下几点:
标准 / 协议 | 描述 |
---|---|
OpenID4VCI | OpenID for Verifiable Credential Issuance。这是我们将使用的核心协议。它定义了一个标准流程,规定用户(通过其钱包)如何从签发者请求并接收凭证。 |
JWT-VC | 基于 JWT 的可验证凭证。这是我们将签发的凭证格式。它是一个 W3C 标准,将可验证凭证编码为 JSON Web Tokens (JWTs),使其紧凑且适合 Web 环境。 |
ISO mDoc | ISO/IEC 18013-5。移动驾照 (mDL) 的国际标准。虽然我们签发的是 JWT-VC,但其中的 claims(声明)结构与 mDoc 数据模型兼容(例如 eu.europa.ec.eudi.pid.1 )。 |
OAuth 2.0 | OpenID4VCI 底层使用的授权框架。我们将实现一个 pre-authorized_code 流程,这是一种专为安全、用户友好的凭证签发而设计的特定授权类型。 |
OpenID4VCI 支持两种主要的凭证签发授权流程:
预授权码流程 (Pre-Authorized Code Flow):
在此流程中,签发者生成一个短暂、一次性的代码 (pre-authorized_code
),并立即提供给用户。用户的钱包可以直接用此代码换取凭证。此流程非常适合用户已经过身份验证并正在访问签发者网站的场景,因为它提供了无缝、即时的签发体验,无需重定向。
授权码流程 (Authorization Code Flow): 这是标准的 OAuth 2.0
流程,用户被重定向到授权服务器以授予同意。批准后,服务器会将一个 authorization_code
发送回注册的 redirect_uri
。此流程更适合代表用户发起签发过程的第三方应用程序。
在本教程中,我们将使用 pre-authorized_code
流程。
我们选择这种方法是因为它更简单,并且为我们的特定用例(用户直接从签发者自己的网站申请凭证)提供了更直接的用户体验。它避免了复杂的重定向和客户端注册,使核心签发逻辑更易于理解和实现。
这些标准的结合使我们能够构建一个与各种数字钱包兼容的签发者,并确保为用户提供一个安全、标准化的流程。
为了构建我们的签发者,我们将使用与验证者相同的稳健、现代的技术栈,以确保一致且高质量的开发体验。
我们将为前端和后端代码都使用 TypeScript。它的静态类型在一个像签发者这样对安全要求很高的应用中非常有价值,因为它有助于防止常见错误,并提高代码的整体质量和可维护性。
Next.js 是我们的首选框架,因为它为构建全栈应用程序提供了无缝、集成的体验。
我们的实现将依赖一些关键库来处理特定任务:
pre-authorized_code
值。要测试你的签发者,你需要一个支持 OpenID4VCI 协议的移动钱包。在本教程中,我们推荐 Sphereon Wallet,它同时支持 Android 和 iOS。
如何安装 Sphereon Wallet:
签发凭证是一项对安全要求极高的操作,它依赖于基本的加密概念来确保信任和真实性。
可验证凭证的核心是一组由签发者数字签名的声明。这个签名提供了两个保证:
数字签名是使用公钥/私钥加密技术创建的。其工作原理如下:
在我们的实现中,我们将生成一个椭圆曲线 (EC) 密钥对,并使用 ES256
算法来签署 JWT-VC。公钥被嵌入到签发者的 DID
(did:web
) 中,允许任何验证者发现它并验证凭证的签名。 注意: 我们的 JWT 中有意省略了
aud
(audience) 声明,因为该凭证设计为通用目的,不与特定钱包绑定。如果你想将使用范围限制在特定受众,请包含一个
aud
声明并相应地进行设置。
我们的签发者应用程序是作为一个全栈 Next.js 项目构建的,前端和后端逻辑清晰分离。这种架构使我们能够创建无缝的用户体验,同时在服务器上处理所有对安全要求至关重要的操作。
重要提示: SQL 中包含的 verification_sessions
和 verified_credentials
表对于此签发者不是必需的,但为了完整性而包含在内。
src/app/issue/page.tsx
): 一个单一的 React
页面,允许用户输入他们的数据以申请凭证。它会调用我们的后端 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 签名。下图展示了签发流程:
现在我们对标准、协议和架构有了扎实的理解,可以开始构建我们的签发者了。
跟着做或直接使用最终代码
我们现在将逐步介绍设置和代码实现。如果你想直接跳到最终成品,可以从我们的 GitHub 仓库克隆完整的项目并在本地运行。
git clone https://github.com/corbado/digital-credentials-example.git
首先,我们将初始化一个新的 Next.js 项目,安装必要的依赖项,并启动我们的数据库。
打开你的终端,导航到你想要创建项目的目录,并运行以下命令。我们在这个项目中使用 App Router、TypeScript 和 Tailwind CSS。
npx create-next-app@latest . --ts --eslint --tailwind --app --src-dir --import-alias "@/*" --use-npm
此命令会在你的当前目录中搭建一个新的 Next.js 应用程序框架。
接下来,我们需要安装处理 JWT、数据库连接和 UUID 生成的库。
npm install jose mysql2 uuid @types/uuid
此命令安装了:
jose
: 用于签署和验证 JSON Web Tokens (JWTs)。mysql2
: 我们数据库的 MySQL 客户端。uuid
: 用于生成唯一的挑战字符串。@types/uuid
: uuid
库的 TypeScript 类型。我们的后端需要一个 MySQL
数据库来存储授权码、签发会话和签发者密钥。我们已经包含了一个 docker-compose.yml
文件来简化这个过程。
如果你已经克隆了仓库,只需运行 docker-compose up -d
。如果你是从头开始构建,创建一个名为
docker-compose.yml
的文件,内容如下:
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
的文件,内容如下,用于为验证者和签发者设置必要的表:
-- 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) );
当这两个文件都准备好后,在项目根目录的终端中运行:
docker-compose up -d
此命令将在后台启动一个 MySQL 容器,供我们的应用程序使用。
在构建 API 端点之前,我们先来创建处理核心业务逻辑的共享库。这种方法可以保持我们的 API 路由清晰,专注于处理 HTTP 请求,而将复杂的工作委托给这些模块。
src/lib/database.ts
)#此文件是所有数据库交互的唯一来源。它使用 mysql2
库连接到我们的 MySQL 容器,并提供了一组导出的函数,用于创建、读取和更新我们表中的记录。这个抽象层使我们的代码更模块化,更易于维护。
创建文件 src/lib/database.ts
,内容如下:
// 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 函数的完整列表。你可以在项目仓库中找到完整的代码。该文件包括管理挑战、验证会话、授权码、签发会话和签发者密钥的函数。
src/lib/crypto.ts
)#此文件处理所有对安全至关重要的加密操作。它使用 jose
库来生成密钥对和签署 JSON Web Tokens
(JWTs)。
密钥生成 generateIssuerKeyPair
函数创建一个新的椭圆曲线密钥对,将用于签署凭证。公钥以 JSON Web Key
(JWK) 格式导出,以便可以发布在我们的 did.json
文档中。
// 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。
// 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,并用签发者的私钥对其进行签名,从而生成一个安全且可验证的凭证。
我们的 Next.js 应用程序结构清晰,将前端和后端分离开来,尽管它们属于同一个项目。这是通过利用 App Router 同时管理 UI 页面和 API 端点来实现的。
前端 (src/app/issue/page.tsx
): 一个单一的 React
页面组件,定义了 /issue
路由的 UI。它处理用户输入并与我们的后端 API 通信。
后端 API 路由 (src/app/api/...
):
.well-known/.../route.ts
):
这些路由暴露了公共元数据端点,允许钱包和其他客户端发现签发者的能力和公钥。issue/.../route.ts
):
这些端点实现了核心的 OpenID4VCI 逻辑,包括创建凭证邀约、签发令牌和签署最终凭证。schemas/pid/route.ts
): 此路由提供凭证的 JSON schema,定义了其结构。库 (src/lib/
): 此目录包含在整个后端共享的可重用逻辑。
database.ts
: 管理所有数据库交互,抽象掉 SQL 查询。crypto.ts
: 处理所有加密操作,例如密钥生成和 JWT 签名。这种清晰的分离使应用程序模块化且易于维护。
注意: generateIssuerDid()
函数必须返回一个与你的签发者域匹配的有效
did:web
。部署时,.well-known/did.json
必须通过 HTTPS 在该域上提供服务,以便验证者验证凭证。
我们的前端是一个单一的 React 页面,提供一个简单的表单供用户申请新的数字凭证。其职责是:
核心逻辑在 handleSubmit
函数中处理,该函数在用户提交表单时触发。
// 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); } };
此函数执行三个关键操作:
/api/issue/authorize
端点发送一个 POST
请求,附带用户数据。文件的其余部分包含用于渲染表单和二维码显示的标准 React 代码。你可以在项目仓库中查看完整文件。
在构建后端 API 之前,我们需要配置我们的环境并设置发现端点。这些 .well-known
文件对于钱包找到我们的签发者并了解如何与之交互至关重要。
在你的项目根目录中创建一个名为 .env.local
的文件,并添加以下行。此 URL 必须是可公开访问的,以便移动钱包能够访问它。对于本地开发,你可以使用像
ngrok 这样的隧道服务来暴露你的
localhost
。
NEXT_PUBLIC_BASE_URL=http://localhost:3000
钱包通过查询标准的 .well-known
URL 来发现签发者的能力。我们需要创建三个这样的端点。
1. 签发者元数据 (/.well-known/openid-credential-issuer
)
这是 OpenID4VCI 的主要发现文件。它告诉钱包关于签发者的一切信息,包括其端点、提供的凭证类型以及支持的加密算法。
创建文件 src/app/.well-known/openid-credential-issuer/route.ts
:
// 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
:
// 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
:
// 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
文件,它将无法验证新的凭证或与更新的端点交互。通过强制客户端在每次请求时获取最新副本,我们确保它们始终拥有最新的信息。
我们面向公众的基础设施的最后一部分是凭证 schema 端点。此路由提供一个 JSON Schema,它正式定义了我们正在签发的 PID 凭证的结构、数据类型和约束。钱包和验证者可以使用此 schema 来验证凭证的内容。
创建文件 src/app/api/schemas/pid/route.ts
,内容如下:
// 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。你可以在项目仓库中找到完整文件。
前端就位后,我们现在需要服务器端逻辑来处理 OpenID4VCI 流程。我们从前端调用的第一个端点开始:/api/issue/authorize
。
/api/issue/authorize
: 创建凭证邀约#此端点负责接收用户数据,生成一个安全的一次性使用代码,并构建一个用户钱包可以理解的
credential_offer
。
以下是核心逻辑:
// 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 }); } }
此端点的关键步骤:
pre-authorized_code
(一个 UUID) 和一个 4 位数的
tx_code
(PIN),以增加一层安全性。pre-authorized_code
被存储在数据库中,并设置了短暂的过期时间。用户数据和 PIN 存储在内存中,与代码关联。credential_offer
对象。此对象告诉钱包签发者的位置、它提供什么凭证以及获取凭证所需的代码。openid-credential-offer://...
) 并将其返回给前端,同时返回 tx_code
供用户查看。/api/issue/token
: 用代码交换令牌#一旦用户扫描了二维码并输入了他们的 PIN,钱包就会向此端点发出 POST
请求。它的工作是验证
pre-authorized_code
和 user_pin
(PIN),如果它们有效,则签发一个短暂的访问令牌。
创建文件 src/app/api/issue/token/route.ts
,内容如下:
// 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 }); } }
此端点的关键步骤:
pre-authorized_code
授权类型。pre-authorized_code
是否存在于数据库中,是否未过期,并且之前未使用过。user_pin
与我们之前存储的 tx_code
进行比较,以确保用户授权了该交易。access_token
和一个 c_nonce
(credential
nonce),这是一个一次性使用的值,以防止对凭证端点的重放攻击。issuance_sessions
记录,将访问令牌与用户数据关联起来。pre-authorized_code
标记为已使用。access_token
和 c_nonce
返回给钱包。/api/issue/credential
: 签发已签名的凭证#这是最后一个也是最重要的端点。钱包使用它从 /token
端点收到的访问令牌向此路由发出一个经过身份验证的 POST
请求。此端点的工作是执行最终验证,创建经过加密签名的凭证,并将其返回给钱包。
创建文件 src/app/api/issue/credential/route.ts
,内容如下:
// 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 }); } }
此端点的关键步骤:
Authorization
头中是否存在有效的 Bearer
令牌,并用它来查找活动的签发会话。src/lib/crypto.ts
中的 createJWTVerifiableCredential
辅助函数来构建并签署 JWT-VC。现在你已经拥有一个完整的、端到端的数字凭证签发者实现。以下是如何在本地运行它,以及将其从概念验证推进到生产就绪应用程序需要考虑的事项。
克隆仓库:
git clone https://github.com/corbado/digital-credentials-example.git cd digital-credentials-example
安装依赖项:
npm install
启动数据库: 确保 Docker 正在运行,然后启动 MySQL 容器:
docker-compose up -d
配置环境并运行隧道:
这是本地测试最关键的一步。由于你的移动钱包需要通过互联网连接到你的开发机器,你必须用一个公共的 HTTPS
URL 暴露你的本地服务器。我们将为此使用 ngrok
。
a. 启动 ngrok:
ngrok http 3000
b. 从 ngrok 输出中复制 HTTPS URL
(例如, https://random-string.ngrok.io
)。c. 创建一个 .env.local
文件并设置该 URL:
NEXT_PUBLIC_BASE_URL=https://<your-ngrok-url>
运行应用程序:
npm run dev
在浏览器中打开
http://localhost:3000/issue
。你现在可以填写表单,生成的二维码将正确指向你的公共 ngrok
URL,从而允许你的移动钱包连接并接收凭证。
ngrok
的重要性#数字凭证协议的设计将安全性作为首要任务。因此,钱包几乎总是拒绝通过不安全的 (http://
) 连接来连接到签发者。整个过程依赖于安全的
HTTPS 连接,这由 SSL 证书启用。
像 ngrok
这样的隧道服务通过创建一个安全的、面向公众的 HTTPS
URL(带有有效的 SSL 证书)并将所有流量转发到你的本地开发服务器,从而同时解决了这两个问题。钱包需要 HTTPS,并会拒绝连接到不安全的 (http://
) 端点。这是测试任何需要与移动设备或外部 webhook 交互的 Web 服务时必不可少的工具。
此示例有意专注于核心签发流程,以便于理解。以下主题被认为超出了范围:
revoked
标志以备将来使用,但此处未提供任何撤销逻辑。pre-authorized_code
流程。完整实现 authorization_code
流程将需要用户同意屏幕和更复杂的 OAuth 2.0 逻辑。就这样!通过几页代码,我们现在拥有了一个完整的、端到端的数字凭证签发者,它能够:
pre-authorized_code
流程。虽然本指南提供了一个坚实的基础,但一个生产就绪的签发者还需要额外的功能,如强大的密钥管理、使用持久化存储代替内存存储、凭证撤销以及全面的安全加固。钱包的兼容性也各不相同;推荐使用 Sphereon Wallet 进行测试,但其他钱包可能不支持此处实现的预授权流程。然而,核心构建块和交互流程将保持不变。通过遵循这些模式,你可以为任何类型的数字凭证构建一个安全且可互操作的签发者。
以下是本教程中使用或引用的一些关键资源、规范和工具:
项目仓库:
关键规范:
did:web
Method: 我们签发者公钥使用的 DID 方法。工具:
库:
Related Articles
Table of Contents