Get your free and exclusive 80-page Banking Passkey Report
Back to Overview

Hướng dẫn xây dựng Trình cấp Thông tin xác thực kỹ thuật số (Dành cho Lập trình viên)

Tìm hiểu cách xây dựng trình cấp Thông tin xác thực có thể xác minh (Verifiable Credential) theo chuẩn W3C bằng giao thức OpenID4VCI. Hướng dẫn từng bước này chỉ cho bạn cách tạo một ứng dụng Next.js có khả năng cấp các thông tin xác thực được ký mã hóa v

Amine

Created: August 20, 2025

Updated: August 21, 2025

Blog-Post-Header-Image

See the original blog version in English here.

DigitalCredentialsDemo Icon

Want to experience digital credentials in action?

Try Digital Credentials

1. Giới thiệu#

Thông tin xác thực kỹ thuật số là một cách mạnh mẽ để chứng minh danh tính và các xác nhận một cách an toàn và bảo vệ quyền riêng tư. Nhưng làm thế nào để người dùng có được những thông tin xác thực này? Đây là lúc vai trò của Issuer (Trình cấp) trở nên quan trọng. Một Issuer là một thực thể đáng tin cậy—chẳng hạn như một cơ quan chính phủ, một trường đại học, hoặc một ngân hàng—chịu trách nhiệm tạo và phân phối các thông tin xác thực được ký điện tử cho người dùng.

Hướng dẫn này cung cấp một bài học toàn diện, từng bước để xây dựng một Issuer Thông tin xác thực kỹ thuật số. Chúng ta sẽ tập trung vào giao thức OpenID for Verifiable Credential Issuance (OpenID4VCI), một tiêu chuẩn hiện đại định nghĩa cách người dùng có thể nhận thông tin xác thực từ một Issuer và lưu trữ chúng an toàn trong kỹ thuật số của họ.

Kết quả cuối cùng sẽ là một ứng dụng Next.js hoạt động được, có khả năng:

  1. Chấp nhận dữ liệu người dùng thông qua một biểu mẫu web đơn giản.
  2. Tạo một đề nghị cấp thông tin xác thực an toàn, dùng một lần.
  3. Hiển thị đề nghị dưới dạng mã QR để người dùng quét bằng di động của họ.
  4. Cấp một thông tin xác thực được ký mã hóa mà người dùng có thể lưu trữ và trình bày để xác minh.

1.1 Tìm hiểu thuật ngữ: Digital Credentials và Verifiable Credentials#

Trước khi tiếp tục, điều quan trọng là phải làm rõ sự khác biệt giữa hai khái niệm liên quan nhưng khác nhau:

  • Digital Credentials (Thông tin xác thực kỹ thuật số - Thuật ngữ chung): Đây là một danh mục rộng bao gồm mọi hình thức kỹ thuật số của thông tin xác thực, chứng chỉ, hoặc chứng thực. Chúng có thể bao gồm các chứng chỉ kỹ thuật số đơn giản, huy hiệu kỹ thuật số cơ bản, hoặc bất kỳ thông tin xác thực nào được lưu trữ điện tử mà có thể có hoặc không có các tính năng bảo mật mã hóa.

  • Verifiable Credentials (VCs - Tiêu chuẩn W3C): Đây là một loại thông tin xác thực kỹ thuật số cụ thể tuân theo tiêu chuẩn Mô hình Dữ liệu Thông tin xác thực có thể xác minh của W3C. Verifiable Credentials là các thông tin xác thực được ký mã hóa, chống giả mạo và tôn trọng quyền riêng tư, có thể được xác minh một cách độc lập. Chúng bao gồm các yêu cầu kỹ thuật cụ thể như:

    • Chữ ký mã hóa để đảm bảo tính xác thực và toàn vẹn
    • Mô hình dữ liệu và định dạng được tiêu chuẩn hóa
    • Cơ chế trình bày bảo vệ quyền riêng tư
    • Các giao thức xác minh có khả năng tương tác

Trong hướng dẫn này, chúng ta sẽ xây dựng một trình cấp Verifiable Credential tuân thủ tiêu chuẩn W3C, chứ không chỉ là một hệ thống thông tin xác thực kỹ thuật số bất kỳ. Giao thức OpenID4VCI mà chúng ta đang sử dụng được thiết kế đặc biệt để cấp Verifiable Credentials, và định dạng JWT-VC chúng ta sẽ triển khai là một định dạng tuân thủ W3C cho Verifiable Credentials.

1.2 Cách hoạt động#

Điều kỳ diệu đằng sau thông tin xác thực kỹ thuật số nằm ở mô hình "tam giác tin cậy" đơn giản nhưng mạnh mẽ, liên quan đến ba bên chính:

  • Issuer: Một cơ quan có thẩm quyền đáng tin cậy (ví dụ: một cơ quan chính phủ, trường đại học, hoặc ngân hàng) ký mã hóa và cấp một thông tin xác thực cho người dùng. Đây là vai trò chúng ta đang xây dựng trong hướng dẫn này.
  • Holder: Người dùng, nhận thông tin xác thực và lưu trữ nó an toàn trong một ví kỹ thuật số cá nhân trên thiết bị của họ.
  • Verifier: Một ứng dụng hoặc dịch vụ cần kiểm tra thông tin xác thực của người dùng.

Luồng cấp phát là bước đầu tiên trong hệ sinh thái này. Issuer xác thực thông tin của người dùng và cung cấp cho họ một thông tin xác thực. Một khi Holder có thông tin xác thực này trong của họ, họ có thể trình bày nó cho một Verifier để chứng minh danh tính hoặc các xác nhận của mình, hoàn thành tam giác.

Đây là cái nhìn nhanh về ứng dụng cuối cùng khi hoạt động:

Bước 1: Nhập dữ liệu người dùng Người dùng điền vào một biểu mẫu với thông tin cá nhân của họ để yêu cầu một thông tin xác thực mới.

Bước 2: Tạo đề nghị cấp thông tin xác thực Ứng dụng tạo ra một đề nghị cấp thông tin xác thực an toàn, được hiển thị dưới dạng mã QR và một mã được ủy quyền trước.

Bước 3: Tương tác với Ví Người dùng quét mã QR bằng một tương thích (ví dụ: Sphereon Wallet) và nhập mã PIN để ủy quyền việc cấp phát.

Bước 4: Thông tin xác thực được cấp Ví nhận và lưu trữ thông tin xác thực kỹ thuật số mới được cấp, sẵn sàng cho việc sử dụng trong tương lai.

2. Các yêu cầu tiên quyết để xây dựng một Issuer#

Trước khi đi sâu vào code, hãy cùng tìm hiểu các kiến thức nền tảng và công cụ bạn sẽ cần. Hướng dẫn này giả định bạn đã quen thuộc cơ bản với các khái niệm phát triển web, nhưng các yêu cầu tiên quyết sau đây là cần thiết để xây dựng một trình cấp thông tin xác thực.

2.1 Lựa chọn giao thức#

Issuer của chúng ta được xây dựng trên một tập hợp các tiêu chuẩn mở đảm bảo khả năng tương tác giữa các và dịch vụ cấp phát. Trong bài hướng dẫn này, chúng ta sẽ tập trung vào các tiêu chuẩn sau:

Tiêu chuẩn / Giao thứcMô tả
OpenID4VCIOpenID for Verifiable Credential Issuance. Đây là giao thức cốt lõi chúng ta sẽ sử dụng. Nó định nghĩa một luồng chuẩn về cách người dùng (thông qua ví của họ) có thể yêu cầu và nhận một thông tin xác thực từ một Issuer.
JWT-VCJWT-based Verifiable Credentials. Định dạng cho thông tin xác thực chúng ta sẽ cấp. Đây là một tiêu chuẩn W3C mã hóa các thông tin xác thực có thể xác minh dưới dạng JSON Web Tokens (JWTs), giúp chúng nhỏ gọn và thân thiện với web.
ISO mDocISO/IEC 18013-5. Tiêu chuẩn quốc tế cho Bằng lái xe di động (mDLs). Mặc dù chúng ta cấp một JWT-VC, các claims bên trong nó được cấu trúc để tương thích với mô hình dữ liệu mDoc (ví dụ: eu.europa.ec.eudi.pid.1).
OAuth 2.0Framework ủy quyền nền tảng được OpenID4VCI sử dụng. Chúng ta sẽ triển khai luồng pre-authorized_code, một loại grant type cụ thể được thiết kế để cấp thông tin xác thực an toàn và thân thiện với người dùng.

2.1.1 Luồng ủy quyền: Pre-Authorized và Authorization Code#

OpenID4VCI hỗ trợ hai luồng ủy quyền chính để cấp thông tin xác thực:

  1. Luồng Pre-Authorized Code: Trong luồng này, Issuer tạo ra một mã ngắn hạn, sử dụng một lần (pre-authorized_code) và cung cấp ngay cho người dùng. Ví của người dùng sau đó có thể đổi mã này trực tiếp để lấy thông tin xác thực. Luồng này lý tưởng cho các tình huống mà người dùng đã được xác thực và đang có mặt trên trang web của Issuer, vì nó mang lại trải nghiệm cấp phát liền mạch, tức thì mà không cần chuyển hướng.

  2. Luồng Authorization Code: Đây là luồng OAuth 2.0 tiêu chuẩn, trong đó người dùng được chuyển hướng đến một máy chủ ủy quyền để cấp quyền. Sau khi phê duyệt, máy chủ gửi một authorization_code trở lại một redirect_uri đã đăng ký. Luồng này phù hợp hơn cho các ứng dụng của bên thứ ba khởi tạo quy trình cấp phát thay mặt người dùng.

Trong bài hướng dẫn này, chúng ta sẽ sử dụng luồng pre-authorized_code. Chúng tôi chọn cách tiếp cận này vì nó đơn giản hơn và cung cấp trải nghiệm người dùng trực tiếp hơn cho trường hợp sử dụng cụ thể của chúng ta: một người dùng trực tiếp yêu cầu một thông tin xác thực từ trang web của chính Issuer. Nó loại bỏ sự cần thiết của các chuyển hướng phức tạp và đăng ký client, làm cho logic cấp phát cốt lõi dễ hiểu và triển khai hơn.

Sự kết hợp các tiêu chuẩn này cho phép chúng ta xây dựng một issuer tương thích với nhiều loại kỹ thuật số và đảm bảo một quy trình an toàn, được tiêu chuẩn hóa cho người dùng.

2.2 Lựa chọn công nghệ#

Để xây dựng issuer của mình, chúng ta sẽ sử dụng cùng một bộ công nghệ mạnh mẽ và hiện đại mà chúng ta đã sử dụng cho verifier, đảm bảo một trải nghiệm phát triển nhất quán và chất lượng cao.

2.2.1 Ngôn ngữ: TypeScript#

Chúng ta sẽ sử dụng TypeScript cho cả code frontend và backend. Kiểu tĩnh của nó là vô giá trong một ứng dụng quan trọng về bảo mật như một issuer, vì nó giúp ngăn ngừa các lỗi phổ biến và cải thiện chất lượng tổng thể cũng như khả năng bảo trì của code.

2.2.2 Framework: Next.js#

Next.js là framework chúng ta lựa chọn vì nó cung cấp một trải nghiệm tích hợp, liền mạch để xây dựng các ứng dụng full-stack.

  • Đối với Frontend: Chúng ta sẽ sử dụng Next.js với React để xây dựng giao diện người dùng nơi người dùng có thể nhập dữ liệu của họ để yêu cầu một thông tin xác thực.
  • Đối với Backend: Chúng ta sẽ tận dụng Next.js API Routes để tạo các endpoint phía máy chủ xử lý luồng OpenID4VCI, từ việc tạo các đề nghị cấp thông tin xác thực đến việc cấp thông tin xác thực đã được ký cuối cùng.

2.2.3 Các thư viện chính#

Việc triển khai của chúng ta sẽ dựa vào một vài thư viện chính để xử lý các tác vụ cụ thể:

  • next, react, và react-dom: Các thư viện cốt lõi cho ứng dụng Next.js của chúng ta.
  • mysql2: Một client MySQL cho Node.js, được sử dụng để lưu trữ mã ủy quyền và dữ liệu phiên.
  • uuid: Một thư viện để tạo các định danh duy nhất, chúng ta sẽ sử dụng nó để tạo các giá trị pre-authorized_code.
  • jose: Một thư viện mạnh mẽ để xử lý JSON Web Signatures (JWS), chúng ta sẽ sử dụng nó để ký mã hóa các thông tin xác thực chúng ta cấp.

2.3 Lấy một ví thử nghiệm#

Để kiểm tra issuer của bạn, bạn sẽ cần một ví di động hỗ trợ giao thức OpenID4VCI. Đối với bài hướng dẫn này, chúng tôi khuyên dùng Sphereon Wallet, có sẵn cho cả AndroidiOS.

Cách cài đặt Sphereon Wallet:

  1. Tải ví từ Google Play Store hoặc Apple App Store.
  2. Cài đặt ứng dụng trên thiết bị di động của bạn.
  3. Sau khi cài đặt, ví đã sẵn sàng để nhận các đề nghị cấp thông tin xác thực bằng cách quét mã QR.

2.4 Kiến thức về mã hóa#

Cấp một thông tin xác thực là một hoạt động quan trọng về bảo mật, dựa trên các khái niệm mã hóa cơ bản để đảm bảo sự tin cậy và tính xác thực.

2.4.1 Chữ ký số#

Về cốt lõi, một Verifiable Credential là một tập hợp các xác nhận đã được ký điện tử bởi Issuer. Chữ ký này cung cấp hai sự đảm bảo:

  • Tính xác thực: Nó chứng minh rằng thông tin xác thực được tạo ra bởi một Issuer hợp pháp.
  • Tính toàn vẹn: Nó chứng minh rằng thông tin xác thực không bị thay đổi kể từ khi nó được cấp.

2.4.2 Mật mã khóa công khai/khóa riêng#

Chữ ký số được tạo ra bằng cách sử dụng mật mã khóa công khai/khóa riêng. Đây là cách nó hoạt động:

  1. Issuer có một cặp khóa: một khóa riêng, được giữ bí mật và an toàn, và một khóa công khai tương ứng, được công khai.
  2. Ký: Khi Issuer tạo một thông tin xác thực, nó sử dụng khóa riêng của mình để tạo ra một chữ ký số duy nhất cho dữ liệu thông tin xác thực.
  3. Xác minh: Một Verifier sau đó có thể sử dụng khóa công khai của Issuer để kiểm tra chữ ký. Nếu việc kiểm tra thành công, Verifier biết rằng thông tin xác thực là xác thực và không bị thay đổi.

Trong quá trình triển khai của chúng ta, chúng ta sẽ tạo một cặp khóa Elliptic Curve (EC) và sử dụng thuật toán ES256 để ký JWT-VC. Khóa công khai được nhúng trong DID của Issuer (did:web), cho phép bất kỳ Verifier nào khám phá nó và xác thực chữ ký của thông tin xác thực. Lưu ý: Claim aud (audience) được cố ý bỏ qua trong các JWT của chúng ta, vì thông tin xác thực được thiết kế để sử dụng chung và không bị ràng buộc với một ví cụ thể. Nếu bạn muốn hạn chế việc sử dụng cho một đối tượng cụ thể, hãy bao gồm một claim aud và đặt nó tương ứng.

3. Tổng quan về kiến trúc#

Ứng dụng Issuer của chúng ta được xây dựng như một dự án Next.js full-stack, với sự phân tách rõ ràng giữa logic frontend và backend. Kiến trúc này cho phép chúng ta tạo ra một trải nghiệm người dùng liền mạch trong khi xử lý tất cả các hoạt động quan trọng về bảo mật trên máy chủ. Quan trọng: Các bảng verification_sessionsverified_credentials được bao gồm trong SQL không bắt buộc cho issuer này nhưng được đưa vào để đầy đủ.

  • Frontend (src/app/issue/page.tsx): Một trang React duy nhất cho phép người dùng nhập dữ liệu của họ để yêu cầu một thông tin xác thực. Nó thực hiện các lệnh gọi API đến backend của chúng ta để khởi tạo quy trình cấp phát.
  • Backend API Routes (src/app/api/issue/...): Một tập hợp các endpoint phía máy chủ triển khai giao thức OpenID4VCI.
    • /.well-known/openid-credential-issuer: Một endpoint siêu dữ liệu công khai. Đây là URL đầu tiên mà một ví sẽ kiểm tra để khám phá các khả năng của issuer, bao gồm máy chủ ủy quyền, endpoint token, endpoint thông tin xác thực, và các loại thông tin xác thực mà nó cung cấp.
    • /.well-known/openid-configuration: Một endpoint khám phá OpenID Connect tiêu chuẩn. Mặc dù liên quan chặt chẽ đến endpoint trên, endpoint này phục vụ cấu hình liên quan đến OIDC rộng hơn và thường được yêu cầu để tương tác với các client OpenID tiêu chuẩn.
    • /.well-known/did.json: DID Document cho issuer của chúng ta. Khi sử dụng phương thức did:web, tệp này được sử dụng để công bố các khóa công khai của issuer, mà các verifier có thể sử dụng để xác thực chữ ký của các thông tin xác thực nó cấp.
    • authorize/route.ts: Tạo một pre-authorized_code và một đề nghị cấp thông tin xác thực.
    • token/route.ts: Đổi pre-authorized_code để lấy một access token.
    • credential/route.ts: Cấp JWT-VC cuối cùng, được ký mã hóa.
    • schemas/pid/route.ts: Cung cấp JSON schema cho thông tin xác thực PID. Điều này cho phép bất kỳ người tiêu dùng nào của thông tin xác thực hiểu được cấu trúc và các kiểu dữ liệu của nó.
  • Thư viện (src/lib/):
    • database.ts: Quản lý tất cả các tương tác cơ sở dữ liệu, chẳng hạn như lưu trữ mã ủy quyền và khóa của issuer.
    • crypto.ts: Xử lý tất cả các hoạt động mã hóa, bao gồm tạo khóa và ký JWT.

Đây là sơ đồ minh họa luồng cấp phát:

4. Xây dựng Issuer#

Bây giờ chúng ta đã có một sự hiểu biết vững chắc về các tiêu chuẩn, giao thức và kiến trúc, chúng ta có thể bắt đầu xây dựng issuer của mình.

Làm theo hoặc sử dụng mã nguồn cuối cùng

Bây giờ chúng ta sẽ đi qua từng bước thiết lập và triển khai mã nguồn. Nếu bạn muốn chuyển thẳng đến sản phẩm hoàn chỉnh, bạn có thể sao chép dự án hoàn chỉnh từ kho lưu trữ GitHub của chúng tôi và chạy nó cục bộ.

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

4.1 Thiết lập dự án#

Đầu tiên, chúng ta sẽ khởi tạo một dự án Next.js mới, cài đặt các phụ thuộc cần thiết và khởi động cơ sở dữ liệu của chúng ta.

4.1.1 Khởi tạo ứng dụng Next.js#

Mở terminal của bạn, điều hướng đến thư mục bạn muốn tạo dự án và chạy lệnh sau. Chúng ta đang sử dụng App Router, TypeScript, và Tailwind CSS cho dự án này.

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

Lệnh này tạo ra một ứng dụng Next.js mới trong thư mục hiện tại của bạn.

4.1.2 Cài đặt các phụ thuộc#

Tiếp theo, chúng ta cần cài đặt các thư viện sẽ xử lý JWT, kết nối cơ sở dữ liệu và tạo UUID.

npm install jose mysql2 uuid @types/uuid

Lệnh này cài đặt:

  • jose: Để ký và xác minh JSON Web Tokens (JWTs).
  • mysql2: Client MySQL cho cơ sở dữ liệu của chúng ta.
  • uuid: Để tạo các chuỗi thách thức duy nhất.
  • @types/uuid: Các kiểu TypeScript cho thư viện uuid.

4.1.3 Khởi động cơ sở dữ liệu#

Backend của chúng ta yêu cầu một cơ sở dữ liệu MySQL để lưu trữ mã ủy quyền, các phiên cấp phát và khóa của issuer. Chúng tôi đã bao gồm một tệp docker-compose.yml để làm điều này trở nên dễ dàng.

Nếu bạn đã sao chép kho lưu trữ, bạn chỉ cần chạy docker-compose up -d. Nếu bạn đang xây dựng từ đầu, hãy tạo một tệp có tên docker-compose.yml với nội dung sau:

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:

Thiết lập Docker Compose này cũng yêu cầu một kịch bản khởi tạo SQL. Tạo một thư mục có tên sql và bên trong nó, một tệp có tên init.sql với nội dung sau để thiết lập các bảng cần thiết cho cả verifier và issuer:

-- 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) );

Khi cả hai tệp đã sẵn sàng, mở terminal của bạn ở thư mục gốc của dự án và chạy:

docker-compose up -d

Lệnh này sẽ khởi động một container MySQL trong nền, sẵn sàng để ứng dụng của chúng ta sử dụng.

4.2 Triển khai các thư viện dùng chung#

Trước khi chúng ta xây dựng các API endpoint, hãy tạo các thư viện dùng chung sẽ xử lý logic nghiệp vụ cốt lõi. Cách tiếp cận này giúp các API route của chúng ta gọn gàng và tập trung vào việc xử lý các yêu cầu HTTP, trong khi công việc phức tạp được ủy thác cho các mô-đun này.

4.2.1 Thư viện cơ sở dữ liệu (src/lib/database.ts)#

Tệp này là nguồn chân lý duy nhất cho tất cả các tương tác cơ sở dữ liệu. Nó sử dụng thư viện mysql2 để kết nối với container MySQL của chúng ta và cung cấp một tập hợp các hàm được xuất ra để tạo, đọc và cập nhật các bản ghi trong các bảng của chúng ta. Lớp trừu tượng này làm cho code của chúng ta trở nên modular và dễ bảo trì hơn.

Tạo tệp src/lib/database.ts với nội dung sau:

// src/lib/database.ts import mysql from "mysql2/promise"; // Database connection configuration 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; } // Data-Access-Object (DAO) functions for each table // ... (e.g., createChallenge, getChallenge, createAuthorizationCode, etc.)

Lưu ý: Để ngắn gọn, danh sách đầy đủ các hàm DAO đã được bỏ qua. Bạn có thể tìm thấy mã nguồn hoàn chỉnh trong kho lưu trữ của dự án. Tệp này bao gồm các hàm để quản lý các thách thức, các phiên xác minh, mã ủy quyền, các phiên cấp phát, và khóa của issuer.

4.2.2 Thư viện mã hóa (src/lib/crypto.ts)#

Tệp này xử lý tất cả các hoạt động mã hóa quan trọng về bảo mật. Nó sử dụng thư viện jose để tạo cặp khóa và ký JSON Web Tokens (JWTs).

Tạo khóa Hàm generateIssuerKeyPair tạo ra một cặp khóa Elliptic Curve mới sẽ được sử dụng để ký các thông tin xác thực. Khóa công khai được xuất ra ở định dạng JSON Web Key (JWK) để nó có thể được công bố trong tài liệu did.json của chúng ta.

// 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; // Assign a unique key ID // ... (private key export and other setup) return { publicKey, privateKey, publicKeyJWK /* ... */ }; }

Tạo thông tin xác thực JWT Hàm createJWTVerifiableCredential là cốt lõi của quy trình cấp phát. Nó lấy các xác nhận của người dùng, cặp khóa của issuer, và các siêu dữ liệu khác, và sử dụng chúng để tạo ra một JWT-VC đã được ký.

// 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 = { // The issuer's DID iss: issuerKeyPair.issuerDid, // The subject's (holder's) DID sub: subjectId, // The time the credential was issued (iat) and when it expires (exp) iat: now, exp: now + oneYear, // The Verifiable Credential data model 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, }, }, }; // Sign the payload with the issuer's private key return await new SignJWT(vcPayload) .setProtectedHeader({ alg: issuerKeyPair.algorithm, kid: issuerKeyPair.keyId, typ: "JWT", }) .sign(issuerKeyPair.privateKey); }

Hàm này xây dựng payload JWT theo Mô hình Dữ liệu Verifiable Credentials của W3C và ký nó bằng khóa riêng của issuer, tạo ra một thông tin xác thực có thể xác minh an toàn.

4.2 Tổng quan kiến trúc của ứng dụng Next.js#

Ứng dụng Next.js của chúng ta được cấu trúc để tách biệt các mối quan tâm giữa frontend và backend, mặc dù chúng là một phần của cùng một dự án. Điều này được thực hiện bằng cách tận dụng App Router cho cả các trang UI và các API endpoint.

  • Frontend (src/app/issue/page.tsx): Một component trang React duy nhất định nghĩa UI cho route /issue. Nó xử lý đầu vào của người dùng và giao tiếp với API backend của chúng ta.

  • Backend API Routes (src/app/api/...):

    • Khám phá (.well-known/.../route.ts): Các route này cung cấp các endpoint siêu dữ liệu công khai cho phép ví và các client khác khám phá các khả năng và khóa công khai của issuer.
    • Cấp phát (issue/.../route.ts): Các endpoint này triển khai logic OpenID4VCI cốt lõi, bao gồm tạo các đề nghị cấp thông tin xác thực, cấp token, và ký thông tin xác thực cuối cùng.
    • Schema (schemas/pid/route.ts): Route này phục vụ JSON schema cho thông tin xác thực, định nghĩa cấu trúc của nó.
  • Thư viện (src/lib/): Thư mục này chứa logic có thể tái sử dụng được chia sẻ trên toàn bộ backend.

    • database.ts: Quản lý tất cả các tương tác cơ sở dữ liệu, trừu tượng hóa các truy vấn SQL.
    • crypto.ts: Xử lý tất cả các hoạt động mã hóa, chẳng hạn như tạo khóa và ký JWT.

Sự phân tách rõ ràng này làm cho ứng dụng trở nên modular và dễ bảo trì hơn.

Lưu ý: Hàm generateIssuerDid() phải trả về một did:web hợp lệ khớp với miền issuer của bạn. Khi được triển khai, .well-known/did.json phải được phục vụ qua HTTPS tại miền đó để các verifier có thể xác thực các thông tin xác thực.

4.3 Xây dựng Frontend#

Frontend của chúng ta là một trang React duy nhất cung cấp một biểu mẫu đơn giản cho người dùng yêu cầu một thông tin xác thực kỹ thuật số mới. Trách nhiệm của nó là:

  • Ghi lại dữ liệu người dùng (tên, ngày sinh, v.v.).
  • Gửi dữ liệu này đến backend của chúng ta để tạo một đề nghị cấp thông tin xác thực.
  • Hiển thị mã QR và PIN kết quả để người dùng quét bằng ví của họ.

Logic cốt lõi được xử lý trong hàm handleSubmit, được kích hoạt khi người dùng gửi biểu mẫu.

// src/app/issue/page.tsx const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setLoading(true); setError(null); setCredentialOffer(null); try { // 1. Validate required fields if (!userData.given_name || !userData.family_name || !userData.birth_date) { throw new Error("Please fill in all required fields"); } // 2. Request a credential offer from the backend 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 || "Failed to create credential offer", ); } // 3. Set the credential offer in state to display the QR code const result = await response.json(); setCredentialOffer(result); } catch (err) { const errorMessage = (err as Error).message || "Unknown error occurred"; setError(errorMessage); } finally { setLoading(false); } };

Hàm này thực hiện ba hành động chính:

  1. Xác thực dữ liệu biểu mẫu để đảm bảo tất cả các trường bắt buộc đã được điền.
  2. Gửi một yêu cầu POST đến endpoint /api/issue/authorize của chúng ta với dữ liệu của người dùng.
  3. Cập nhật trạng thái của component với đề nghị cấp thông tin xác thực nhận được từ backend, điều này kích hoạt UI để hiển thị mã QR và mã giao dịch.

Phần còn lại của tệp chứa code React tiêu chuẩn để hiển thị biểu mẫu và màn hình hiển thị mã QR. Bạn có thể xem tệp hoàn chỉnh trong kho lưu trữ của dự án.

4.4 Thiết lập Môi trường và Khám phá#

Trước khi chúng ta xây dựng API backend, chúng ta cần cấu hình môi trường của mình và thiết lập các endpoint khám phá. Các tệp .well-known này rất quan trọng để ví có thể tìm thấy issuer của chúng ta và hiểu cách tương tác với nó.

4.4.1 Tạo tệp môi trường#

Tạo một tệp có tên .env.local trong thư mục gốc của dự án và thêm dòng sau. URL này phải có thể truy cập công khai để một ví di động có thể tiếp cận nó. Để phát triển cục bộ, bạn có thể sử dụng một dịch vụ đường hầm như ngrok để public localhost của bạn.

NEXT_PUBLIC_BASE_URL=http://localhost:3000

4.4.2 Triển khai các Endpoint khám phá#

Các ví khám phá các khả năng của một issuer bằng cách truy vấn các URL .well-known tiêu chuẩn. Chúng ta cần tạo ba trong số các endpoint này.

1. Siêu dữ liệu Issuer (/.well-known/openid-credential-issuer)

Đây là tệp khám phá chính cho OpenID4VCI. Nó cho ví biết mọi thứ cần thiết về issuer, bao gồm các endpoint của nó, các loại thông tin xác thực nó cung cấp, và các thuật toán mã hóa được hỗ trợ.

Tạo tệp 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 = { // The issuer's unique identifier. issuer: baseUrl, // The URL of the authorization server. For simplicity, our issuer is its own authorization server. authorization_servers: [baseUrl], // The URL of the credential issuer. credential_issuer: baseUrl, // The endpoint where the wallet will POST to receive the actual credential. credential_endpoint: `${baseUrl}/api/issue/credential`, // The endpoint where the wallet exchanges an authorization code for an access token. token_endpoint: `${baseUrl}/api/issue/token`, // The endpoint for the authorization flow (not used in our pre-authorized flow, but good practice to include). authorization_endpoint: `${baseUrl}/api/issue/authorize`, // Indicates support for the pre-authorized code flow without requiring client authentication. pre_authorized_grant_anonymous_access_supported: true, // Human-readable information about the issuer. display: [ { name: "Corbado Credentials Issuer", locale: "en-US", }, ], // A list of the credential types this issuer can issue. credential_configurations_supported: { "eu.europa.ec.eudi.pid.1": { // The format of the credential (e.g., jwt_vc, mso_mdoc). format: "jwt_vc", // The specific document type, conforming to ISO mDoc standards. doctype: "eu.europa.ec.eudi.pid.1", // The OAuth 2.0 scope associated with this credential type. scope: "eu.europa.ec.eudi.pid.1", // Methods the wallet can use to prove possession of its key. cryptographic_binding_methods_supported: ["jwk"], // Signing algorithms the issuer supports for this credential. credential_signing_alg_values_supported: ["ES256"], // Proof-of-possession types the wallet can use. proof_types_supported: { jwt: { proof_signing_alg_values_supported: ["ES256", "ES384", "ES512"], }, }, // Display properties for the credential. display: [ { name: "Corbado Credential Issuer", locale: "en-US", logo: { uri: `${baseUrl}/logo.png`, alt_text: "EU Digital Identity", }, background_color: "#003399", text_color: "#FFFFFF", }, ], // A list of the claims (attributes) in the credential. 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" }], }, }, }, }, }, // Authentication methods supported by the token endpoint. 'none' means public client. token_endpoint_auth_methods_supported: ["none"], // PKCE code challenge methods supported. code_challenge_methods_supported: ["S256"], // OAuth 2.0 grant types the issuer supports. 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. Cấu hình OpenID (/.well-known/openid-configuration)

Đây là một tài liệu khám phá OIDC tiêu chuẩn cung cấp một tập hợp các chi tiết cấu hình rộng hơn.

Tạo tệp 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 = { // The issuer's unique identifier. credential_issuer: baseUrl, // The endpoint where the wallet will POST to receive the actual credential. credential_endpoint: `${baseUrl}/api/issue/credential`, // The endpoint for the authorization flow. authorization_endpoint: `${baseUrl}/api/issue/authorize`, // The endpoint where the wallet exchanges an authorization code for an access token. token_endpoint: `${baseUrl}/api/issue/token`, // A list of the credential types this issuer can issue. 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 the issuer supports. grant_types_supported: [ "authorization_code", "urn:ietf:params:oauth:grant-type:pre-authorized_code", ], // Indicates support for the pre-authorized code flow. pre_authorized_grant_anonymous_access_supported: true, // PKCE code challenge methods supported. code_challenge_methods_supported: ["S256"], // Authentication methods supported by the token endpoint. token_endpoint_auth_methods_supported: ["none"], // OAuth 2.0 scopes the issuer supports. 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. Tài liệu DID (/.well-known/did.json)

Tệp này công bố khóa công khai của issuer bằng phương thức did:web, cho phép bất kỳ ai xác minh chữ ký của các thông tin xác thực do nó cấp.

Tạo tệp 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 = { // The context defines the vocabulary used in the document. "@context": [ "https://www.w3.org/ns/did/v1", "https://w3id.org/security/suites/jws-2020/v1", ], // The DID URI, which is the unique identifier for the issuer. id: didId, // The DID controller, which is the entity that controls the DID. Here, it's the issuer itself. controller: didId, // A list of public keys that can be used to verify signatures from the issuer. verificationMethod: [ { // A unique identifier for the key, scoped to the DID. id: `${didId}#${issuerKey.key_id}`, // The type of the key. type: "JsonWebKey2020", // The DID of the key's controller. controller: didId, // The public key in JWK format. publicKeyJwk: publicKeyJWK, }, ], // Specifies which keys can be used for authentication (proving control of the DID). authentication: [`${didId}#${issuerKey.key_id}`], // Specifies which keys can be used for creating verifiable credentials. assertionMethod: [`${didId}#${issuerKey.key_id}`], // A list of services provided by the DID subject, such as the issuer endpoint. 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", }, }); }

Tại sao không cache? Bạn sẽ nhận thấy rằng cả ba endpoint này đều trả về các header ngăn chặn việc cache một cách quyết liệt (Cache-Control: no-cache, Pragma: no-cache, Expires: 0). Đây là một thực hành bảo mật quan trọng đối với các tài liệu khám phá. Cấu hình của Issuer có thể thay đổi—ví dụ, một khóa mã hóa có thể được xoay vòng. Nếu một ví hoặc client lưu vào bộ đệm một phiên bản cũ của tệp did.json hoặc openid-credential-issuer, nó sẽ không thể xác thực các thông tin xác thực mới hoặc tương tác với các endpoint đã cập nhật. Bằng cách buộc các client phải lấy một bản sao mới trong mỗi yêu cầu, chúng tôi đảm bảo họ luôn có thông tin cập nhật nhất.

4.4.3 Triển khai Endpoint Schema Thông tin xác thực#

Phần cuối cùng của cơ sở hạ tầng công khai của chúng ta là endpoint schema thông tin xác thực. Route này phục vụ một JSON Schema định nghĩa chính thức cấu trúc, các kiểu dữ liệu và các ràng buộc của thông tin xác thực PID mà chúng ta đang cấp. Ví và verifier có thể sử dụng schema này để xác thực nội dung của thông tin xác thực.

Tạo tệp src/app/api/schemas/pid/route.ts với nội dung sau:

// 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", // Replace with your actual domain 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" }, // ... other properties of the credential subject }, required: ["given_name", "family_name", "birth_date"], }, // ... other top-level properties of a Verifiable Credential }, }; return NextResponse.json(schema, { headers: { "Content-Type": "application/schema+json", "Access-Control-Allow-Origin": "*", // Allow cross-origin requests }, }); }

Lưu ý: JSON Schema cho một thông tin xác thực PID có thể khá lớn và chi tiết. Để ngắn gọn, schema đầy đủ đã được cắt bớt. Bạn có thể tìm thấy tệp hoàn chỉnh trong kho lưu trữ của dự án.

4.5 Xây dựng các Endpoint Backend#

Với frontend đã sẵn sàng, bây giờ chúng ta cần logic phía máy chủ để xử lý luồng OpenID4VCI. Chúng ta sẽ bắt đầu với endpoint đầu tiên mà frontend gọi: /api/issue/authorize.

4.5.1 /api/issue/authorize: Tạo đề nghị cấp thông tin xác thực#

Endpoint này chịu trách nhiệm lấy dữ liệu của người dùng, tạo ra một mã sử dụng một lần an toàn, và xây dựng một credential_offer mà ví của người dùng có thể hiểu được.

Đây là logic cốt lõi:

// 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. Validate user data 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. Generate a pre-authorized code and a PIN const code = uuidv4(); const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes const txCode = Math.floor(1000 + Math.random() * 9000).toString(); // 4-digit PIN // 3. Store the code and user data await createAuthorizationCode(uuidv4(), code, expiresAt); // Note: This uses an in-memory store for demo purposes only. // In production, persist data securely in a database with proper expiry. 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. Create the credential offer object const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3000"; const credentialOffer = { // The issuer's identifier, which is its base URL. credential_issuer: baseUrl, // An array of credential types the issuer is offering. credential_configuration_ids: ["eu.europa.ec.eudi.pid.1"], // Specifies the grant types the wallet can use. grants: { // We are using the pre-authorized code flow. "urn:ietf:params:oauth:grant-type:pre-authorized_code": { // The one-time code the wallet will exchange for a token. "pre-authorized_code": code, // Indicates that the user must enter a PIN (tx_code) to redeem the code. user_pin_required: true, }, }, }; // 5. Create the full credential offer URI (a deep link for wallets) const credentialOfferUri = `openid-credential-offer://?credential_offer=${encodeURIComponent( JSON.stringify(credentialOffer), )}`; // The final response to the frontend. return NextResponse.json({ // The deep link for the QR code. credential_offer_uri: credentialOfferUri, // The raw pre-authorized code, for display or manual entry. pre_authorized_code: code, // The 4-digit PIN the user must enter in their wallet. tx_code: txCode, }); } catch (error) { console.error("Authorization error:", error); return NextResponse.json({ error: "server_error" }, { status: 500 }); } }

Các bước chính trong endpoint này:

  1. Xác thực dữ liệu: Đầu tiên, nó đảm bảo dữ liệu người dùng bắt buộc có mặt.
  2. Tạo mã: Nó tạo ra một pre-authorized_code duy nhất (một UUID) và một tx_code 4 chữ số (PIN) để tăng thêm một lớp bảo mật.
  3. Lưu trữ dữ liệu: pre-authorized_code được lưu trữ trong cơ sở dữ liệu với thời gian hết hạn ngắn. Dữ liệu của người dùng và mã PIN được lưu trữ trong bộ nhớ, liên kết với mã.
  4. Xây dựng đề nghị: Nó xây dựng đối tượng credential_offer theo đặc tả của OpenID4VCI. Đối tượng này cho ví biết issuer ở đâu, nó cung cấp thông tin xác thực nào, và mã cần thiết để nhận chúng.
  5. Trả về URI: Cuối cùng, nó tạo ra một URI deep link (openid-credential-offer://...) và trả về cho frontend, cùng với tx_code để người dùng xem.

4.5.2 /api/issue/token: Đổi mã lấy token#

Khi người dùng quét mã QR và nhập mã PIN, ví sẽ thực hiện một yêu cầu POST đến endpoint này. Công việc của nó là xác thực pre-authorized_codeuser_pin (PIN), và nếu chúng hợp lệ, cấp một access token ngắn hạn.

Tạo tệp src/app/api/issue/token/route.ts với nội dung sau:

// 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. Validate the grant type if (grant_type !== "urn:ietf:params:oauth:grant-type:pre-authorized_code") { return NextResponse.json( { error: "unsupported_grant_type" }, { status: 400 }, ); } // 2. Validate the pre-authorized code const authCode = await getAuthorizationCode(code); if (!authCode) { return NextResponse.json( { error: "invalid_grant", error_description: "Invalid or expired code", }, { status: 400 }, ); } // 3. Validate the 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. Generate access token and c_nonce const accessToken = uuidv4(); const cNonce = uuidv4(); const cNonceExpiresAt = new Date(Date.now() + 5 * 60 * 1000); // 5 minutes // 5. Create a new issuance session const userData = (global as any).userDataStore?.get(code); await createIssuanceSession( uuidv4(), authCode.id, accessToken, cNonce, cNonceExpiresAt, userData, ); // 6. Mark the code as used and clean up temporary data await markAuthorizationCodeAsUsed(code); (global as any).txCodeStore?.delete(code); (global as any).userDataStore?.delete(code); // 7. Return the access token response return NextResponse.json({ access_token: accessToken, token_type: "Bearer", expires_in: 3600, // 1 hour c_nonce: cNonce, c_nonce_expires_in: 300, // 5 minutes }); } catch (error) { console.error("Token endpoint error:", error); return NextResponse.json({ error: "server_error" }, { status: 500 }); } }

Các bước chính trong endpoint này:

  1. Xác thực loại grant: Nó đảm bảo ví đang sử dụng loại grant pre-authorized_code chính xác.
  2. Xác thực mã: Nó kiểm tra xem pre-authorized_code có tồn tại trong cơ sở dữ liệu, chưa hết hạn và chưa được sử dụng.
  3. Xác thực PIN: Nó so sánh user_pin từ ví với tx_code mà chúng ta đã lưu trữ trước đó để đảm bảo người dùng đã ủy quyền giao dịch.
  4. Tạo token: Nó tạo ra một access_token an toàn và một c_nonce (credential nonce), là một giá trị sử dụng một lần để ngăn chặn các cuộc tấn công phát lại trên endpoint thông tin xác thực.
  5. Tạo phiên: Nó tạo một bản ghi issuance_sessions mới trong cơ sở dữ liệu, liên kết access token với dữ liệu của người dùng.
  6. Đánh dấu mã đã sử dụng: Để ngăn chặn cùng một đề nghị được sử dụng hai lần, nó đánh dấu pre-authorized_code là đã sử dụng.
  7. Trả về token: Nó trả về access_tokenc_nonce cho ví.

4.5.3 /api/issue/credential: Cấp thông tin xác thực đã được ký#

Đây là endpoint cuối cùng và quan trọng nhất. Ví sử dụng access token nó nhận được từ endpoint /token để thực hiện một yêu cầu POST đã được xác thực đến route này. Công việc của endpoint này là thực hiện xác thực cuối cùng, tạo ra thông tin xác thực được ký mã hóa, và trả về cho ví.

Tạo tệp src/app/api/issue/credential/route.ts với nội dung sau:

// 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. Validate the Bearer token 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. Get the user data from the session const userData = session.user_data; if (!userData) { return NextResponse.json({ error: "missing_user_data" }, { status: 400 }); } // 3. Get the active issuer key const issuerKey = await getActiveIssuerKey(); if (!issuerKey) { // In a real application, you would have a more robust key management system. // For this demo, we can generate a key on the fly if one doesn't exist. // This part is omitted for brevity but is in the repository. return NextResponse.json( { error: "server_error", error_description: "Failed to get issuer key", }, { status: 500 }, ); } // 4. Create the 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. Store the issued credential in the database await createIssuedCredential(/* ... credential details ... */); await updateIssuanceSession(session.id, "credential_issued"); // 6. Return the signed credential return NextResponse.json({ format: "jwt_vc", credential: credentialData, c_nonce: uuidv4(), // A new nonce for subsequent requests c_nonce_expires_in: 300, }); } catch (error) { console.error("Credential endpoint error:", error); return NextResponse.json({ error: "server_error" }, { status: 500 }); } }

Các bước chính trong endpoint này:

  1. Xác thực token: Nó kiểm tra một token Bearer hợp lệ trong header Authorization và sử dụng nó để tra cứu phiên cấp phát đang hoạt động.
  2. Truy xuất dữ liệu người dùng: Nó truy xuất dữ liệu xác nhận của người dùng, đã được lưu trữ trong phiên khi token được tạo.
  3. Tải khóa của issuer: Nó tải khóa ký đang hoạt động của issuer từ cơ sở dữ liệu. Trong một kịch bản thực tế, điều này sẽ được quản lý bởi một hệ thống quản lý khóa an toàn.
  4. Tạo thông tin xác thực: Nó gọi hàm trợ giúp createJWTVerifiableCredential của chúng ta từ src/lib/crypto.ts để xây dựng và ký JWT-VC.
  5. Ghi lại việc cấp phát: Nó lưu một bản ghi về thông tin xác thực đã cấp vào cơ sở dữ liệu cho mục đích kiểm toán và thu hồi.
  6. Trả về thông tin xác thực: Nó trả về thông tin xác thực đã được ký cho ví trong một phản hồi JSON. Ví sau đó có trách nhiệm lưu trữ nó một cách an toàn.

5. Chạy Issuer và các bước tiếp theo#

Bây giờ bạn đã có một triển khai hoàn chỉnh, từ đầu đến cuối của một issuer thông tin xác thực kỹ thuật số. Đây là cách chạy nó cục bộ và những gì bạn cần xem xét để đưa nó từ một bằng chứng khái niệm thành một ứng dụng sẵn sàng cho sản xuất.

5.1 Cách chạy ví dụ#

  1. Sao chép Kho lưu trữ:

    git clone https://github.com/corbado/digital-credentials-example.git cd digital-credentials-example
  2. Cài đặt các phụ thuộc:

    npm install
  3. Khởi động cơ sở dữ liệu: Đảm bảo Docker đang chạy, sau đó khởi động container MySQL:

    docker-compose up -d
  4. Cấu hình Môi trường & Chạy Tunnel: Đây là bước quan trọng nhất để kiểm tra cục bộ. Vì ví di động của bạn cần kết nối với máy phát triển của bạn qua internet, bạn phải public máy chủ cục bộ của mình bằng một URL HTTPS công khai. Chúng ta sẽ sử dụng ngrok cho việc này.

    a. Khởi động ngrok:

    ngrok http 3000

    b. Sao chép URL HTTPS từ đầu ra của ngrok (ví dụ: https://random-string.ngrok.io). c. Tạo một tệp .env.local và đặt URL:

    NEXT_PUBLIC_BASE_URL=https://<your-ngrok-url>
  5. Chạy ứng dụng:

    npm run dev

    Mở trình duyệt của bạn đến http://localhost:3000/issue. Bây giờ bạn có thể điền vào biểu mẫu, và mã QR được tạo ra sẽ trỏ đúng đến URL ngrok công khai của bạn, cho phép ví di động của bạn kết nối và nhận thông tin xác thực.

5.2 Tầm quan trọng của HTTPS và ngrok#

Các giao thức thông tin xác thực kỹ thuật số được xây dựng với ưu tiên hàng đầu là bảo mật. Vì lý do này, các ví gần như luôn từ chối kết nối với một issuer qua một kết nối không an toàn (http://). Toàn bộ quá trình dựa trên một kết nối HTTPS an toàn, được kích hoạt bởi một chứng chỉ SSL.

Một dịch vụ tunnel như ngrok giải quyết cả hai vấn đề bằng cách tạo ra một URL HTTPS công khai, an toàn (với một chứng chỉ SSL hợp lệ) chuyển tiếp tất cả lưu lượng truy cập đến máy chủ phát triển cục bộ của bạn. Các ví yêu cầu HTTPS và sẽ từ chối kết nối với các endpoint không an toàn (http://). Đây là một công cụ thiết yếu để kiểm tra bất kỳ dịch vụ web nào cần tương tác với các thiết bị di động hoặc webhook bên ngoài.

5.3 Những gì nằm ngoài phạm vi của hướng dẫn này#

Ví dụ này được cố ý tập trung vào luồng cấp phát cốt lõi để dễ hiểu. Các chủ đề sau được coi là nằm ngoài phạm vi:

  • Bảo mật sẵn sàng cho sản xuất: Issuer này chỉ dành cho mục đích giáo dục. Một hệ thống sản xuất sẽ yêu cầu một Hệ thống Quản lý Khóa (KMS) an toàn thay vì lưu trữ khóa trong cơ sở dữ liệu, xử lý lỗi mạnh mẽ, giới hạn tốc độ, và ghi nhật ký kiểm toán toàn diện.
  • Thu hồi thông tin xác thực: Hướng dẫn này không triển khai cơ chế thu hồi các thông tin xác thực đã cấp. Mặc dù schema bao gồm một cờ revoked để sử dụng trong tương lai, nhưng không có logic thu hồi nào được cung cấp ở đây.
  • Luồng Authorization Code: Chúng tôi tập trung hoàn toàn vào luồng pre-authorized_code. Một triển khai đầy đủ của luồng authorization_code sẽ yêu cầu một màn hình chấp thuận của người dùng và logic OAuth 2.0 phức tạp hơn.
  • Quản lý người dùng: Hướng dẫn không bao gồm bất kỳ xác thực hoặc quản lý người dùng nào cho chính issuer. Giả định rằng người dùng đã được xác thực và được ủy quyền để nhận một thông tin xác thực.

6. Kết luận#

Thế là xong! Với một vài trang code, bây giờ chúng ta đã có một issuer thông tin xác thực kỹ thuật số hoàn chỉnh, từ đầu đến cuối, có khả năng:

  1. Cung cấp một frontend thân thiện với người dùng để yêu cầu thông tin xác thực.
  2. Triển khai luồng OpenID4VCI pre-authorized_code đầy đủ.
  3. Cung cấp tất cả các endpoint khám phá cần thiết cho khả năng tương tác của ví.
  4. Tạo và ký một JWT-Verifiable Credential an toàn, tuân thủ tiêu chuẩn.

Mặc dù hướng dẫn này cung cấp một nền tảng vững chắc, một issuer sẵn sàng cho sản xuất sẽ yêu cầu các tính năng bổ sung như quản lý khóa mạnh mẽ, lưu trữ bền vững thay vì lưu trữ trong bộ nhớ, thu hồi thông tin xác thực, và tăng cường bảo mật toàn diện. Khả năng tương thích của ví cũng khác nhau; Sphereon Wallet được khuyến nghị để kiểm tra, nhưng các ví khác có thể không hỗ trợ luồng pre-authorized như đã triển khai ở đây. Tuy nhiên, các khối xây dựng cốt lõi và luồng tương tác sẽ vẫn giữ nguyên. Bằng cách tuân theo các mẫu này, bạn có thể xây dựng một issuer an toàn và có khả năng tương tác cho bất kỳ loại thông tin xác thực kỹ thuật số nào.

7. Tài nguyên#

Đây là một số tài nguyên, thông số kỹ thuật và công cụ chính được sử dụng hoặc tham chiếu trong hướng dẫn này:

Add passkeys to your app in <1 hour with our UI components, SDKs & guides.

Start Free Trial

Share this article


LinkedInTwitterFacebook

Table of Contents