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

デジタルクレデンシャル発行者の構築方法(開発者ガイド)

OpenID4VCIプロトコルを使用してW3C検証可能なクレデンシャル発行者を構築する方法を学びましょう。このステップバイステップガイドでは、デジタルウォレットと互換性のある暗号署名付きクレデンシャルを発行するNext.jsアプリケーションの作成方法を解説します。

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. はじめに#

デジタルクレデンシャルは、安全でプライバシーを保護する方法で身元や主張を証明するための強力な手段です。しかし、ユーザーはそもそもどのようにしてこれらのクレデンシャルを入手するのでしょうか?ここで重要になるのがIssuerの役割です。Issuerとは、政府機関、大学、銀行などの信頼できる事業体で、デジタル署名されたクレデンシャルを作成し、ユーザーに配布する責任を担います。

このガイドでは、デジタルクレデンシャルIssuerを構築するための包括的なステップバイステップのチュートリアルを提供します。今回は、ユーザーがIssuerからクレデンシャルを取得し、自身のデジタルウォレットに安全に保管する方法を定義する最新の標準である**OpenID for Verifiable Credential Issuance (OpenID4VCI)**プロトコルに焦点を当てます。

最終的に、以下の機能を持つNext.jsアプリケーションが完成します。

  1. シンプルなWebフォームを通じてユーザーデータを受け入れる。
  2. 安全なワンタイムのクレデンシャルオファーを生成する。
  3. ユーザーがモバイルウォレットでスキャンできるように、オファーをQRコードとして表示する。
  4. ユーザーが保管し、検証のために提示できる暗号署名付きのクレデンシャルを発行する。

1.1 用語の理解:デジタルクレデンシャル vs. 検証可能なクレデンシャル#

先に進む前に、関連しているものの異なる2つの概念の違いを明確にしておくことが重要です。

  • デジタルクレデンシャル(一般用語): これは、クレデンシャル、証明書、または構成証明のあらゆるデジタル形式を包括する広範なカテゴリです。これらには、単純なデジタル証明書、基本的なデジタルバッジ、または暗号化によるセキュリティ機能を持つ場合も持たない場合もある電子的に保存されたクレデンシャルが含まれます。

  • 検証可能なクレデンシャル(VCs - W3C標準): これは、W3C Verifiable Credentials Data Model標準に準拠した特定のタイプのデジタルクレデンシャルです。検証可能なクレデンシャルは、暗号署名され、改ざんが検知可能で、プライバシーを尊重するクレデンシャルであり、独立して検証できます。次のような特定の技術的要件が含まれます。

    • 真正性と完全性のための暗号署名
    • 標準化されたデータモデルとフォーマット
    • プライバシーを保護する提示メカニズム
    • 相互運用可能な検証プロトコル

**このガイドでは、単なるデジタルクレデンシャルシステムではなく、W3C標準に準拠した検証可能なクレデンシャル発行者を具体的に構築します。**私たちが使用するOpenID4VCIプロトコルは、検証可能なクレデンシャルの発行専用に設計されており、実装するJWT-VCフォーマットは、検証可能なクレデンシャルのためのW3C準拠のフォーマットです。

1.2 仕組み#

デジタルクレデンシャルの背後にある魔法は、3つの主要なプレイヤーが関わるシンプルかつ強力な「信頼の三角形」モデルにあります。

  • Issuer: 信頼できる機関(例:政府機関、大学、銀行)で、クレデンシャルに暗号署名してユーザーに発行します。これがこのガイドで構築する役割です。
  • Holder: ユーザーであり、クレデンシャルを受け取り、デバイス上の個人のデジタルウォレットに安全に保管します。
  • Verifier: ユーザーのクレデンシャルを確認する必要があるアプリケーションまたはサービスです。

発行フローは、このエコシステムの最初のステップです。Issuerはユーザーの情報を検証し、クレデンシャルを提供します。Holderがこのクレデンシャルをウォレットに持つと、Verifierに提示して身元や主張を証明し、三角形が完成します。

最終的なアプリケーションの動作を簡単に見てみましょう。

ステップ1:ユーザーデータの入力 ユーザーは、新しいクレデンシャルをリクエストするために個人情報をフォームに入力します。

ステップ2:クレデンシャルオファーの生成 アプリケーションは、安全なクレデンシャルオファーを生成し、QRコードと事前承認コードとして表示します。

ステップ3:ウォレットとの対話 ユーザーは、互換性のあるウォレット(例:Sphereon Wallet)でQRコードをスキャンし、PINを入力して発行を承認します。

ステップ4:クレデンシャルの発行 ウォレットは、新しく発行されたデジタルクレデンシャルを受け取り、将来の使用に備えて保管します。

2. Issuer構築の前提条件#

コードに入る前に、必要となる基礎知識とツールについて説明します。このガイドは、Web開発の基本的な概念に精通していることを前提としていますが、クレデンシャル発行者を構築するためには以下の前提条件が不可欠です。

2.1 プロトコルの選択#

私たちのIssuerは、ウォレットと発行サービス間の相互運用性を確保するための一連のオープンスタンダードに基づいて構築されています。このチュートリアルでは、以下に焦点を当てます。

標準 / プロトコル説明
OpenID4VCI**OpenID for Verifiable Credential Issuance。**これが私たちが使用するコアプロトコルです。ユーザー(ウォレット経由)がIssuerにクレデンシャルを要求し、受け取るための標準的なフローを定義します。
JWT-VC**JWTベースの検証可能なクレデンシャル。**私たちが発行するクレデンシャルのフォーマットです。これは検証可能なクレデンシャルをJSON Web Token(JWT)としてエンコードするW3C標準であり、コンパクトでWebフレンドリーです。
ISO mDoc**ISO/IEC 18013-5。**モバイル運転免許証(mDL)の国際標準です。私たちはJWT-VCを発行しますが、その中の_claims_はmDocデータモデル(例:eu.europa.ec.eudi.pid.1)と互換性があるように構成されています。
OAuth 2.0OpenID4VCIが使用する基盤となる認可フレームワークです。私たちはpre-authorized_codeフローを実装します。これは、安全でユーザーフレンドリーなクレデンシャル発行のために設計された特定のグラントタイプです。

2.1.1 認可フロー:事前承認コード vs. 認可コード#

OpenID4VCIは、クレデンシャルを発行するための2つの主要な認可フローをサポートしています。

  1. 事前承認コードフロー(Pre-Authorized Code Flow): このフローでは、Issuerは短命で一度しか使えないコード(pre-authorized_code)を生成し、すぐにユーザーに提供します。ユーザーのウォレットはこのコードを直接クレデンシャルと交換できます。このフローは、ユーザーが既にIssuerのウェブサイトで認証され、その場にいるシナリオに最適です。リダイレクトなしでシームレスかつ即時の発行体験を提供します。

  2. 認可コードフロー(Authorization Code Flow): これは標準的なOAuth 2.0フローで、ユーザーは同意を与えるために認可サーバーにリダイレクトされます。承認後、サーバーは登録されたredirect_uriauthorization_codeを送り返します。このフローは、ユーザーに代わって発行プロセスを開始するサードパーティアプリケーションに適しています。

**このチュートリアルでは、pre-authorized_codeフローを使用します。**このアプローチを選択したのは、よりシンプルで、私たちの特定のユースケース(ユーザーがIssuerのウェブサイトから直接クレデンシャルを要求する)に対してより直接的なユーザー体験を提供するためです。複雑なリダイレクトやクライアント登録の必要がなくなり、コアとなる発行ロジックをより理解しやすく、実装しやすくなります。

この標準の組み合わせにより、私たちは幅広いデジタルウォレットと互換性があり、ユーザーにとって安全で標準化されたプロセスを保証するIssuerを構築することができます。

2.2 技術スタックの選択#

Issuerを構築するために、Verifierで使用したものと同じ堅牢でモダンな技術スタックを使用し、一貫性のある高品質な開発者体験を保証します。

2.2.1 言語:TypeScript#

フロントエンドとバックエンドの両方のコードにTypeScriptを使用します。その静的型付けは、Issuerのようなセキュリティが重要なアプリケーションにおいて非常に価値があり、一般的なエラーを防ぎ、コード全体の品質と保守性を向上させるのに役立ちます。

2.2.2 フレームワーク:Next.js#

Next.jsは、フルスタックアプリケーションを構築するためのシームレスで統合された体験を提供するため、私たちが選んだフレームワークです。

  • フロントエンド: Next.jsReactを使用して、ユーザーがクレデンシャルを要求するためにデータを入力するユーザーインターフェースを構築します。
  • バックエンド: Next.js API Routesを活用して、クレデンシャルオファーの生成から最終的な署名済みクレデンシャルの発行まで、OpenID4VCIフローを処理するサーバーサイドのエンドポイントを作成します。

2.2.3 主要ライブラリ#

私たちの実装は、特定のタスクを処理するためにいくつかの主要なライブラリに依存します。

  • next, react, react-dom:Next.jsアプリケーションのコアライブラリ。
  • mysql2Node.js用のMySQLクライアント。認可コードとセッションデータを保存するために使用します。
  • uuid:一意の識別子を生成するためのライブラリ。pre-authorized_codeの値を作成するために使用します。
  • jose:JSON Web Signature (JWS)を扱うための堅牢なライブラリ。私たちが発行するクレデンシャルに暗号署名するために使用します。

2.3 テスト用ウォレットの入手#

Issuerをテストするには、OpenID4VCIプロトコルをサポートするモバイルウォレットが必要です。このチュートリアルでは、AndroidiOSの両方で利用可能なSphereon Walletをお勧めします。

Sphereon Walletのインストール方法:

  1. Google PlayストアまたはApple App Storeからウォレットをダウンロードします。
  2. モバイルデバイスにアプリをインストールします。
  3. インストールが完了すると、ウォレットはQRコードをスキャンしてクレデンシャルオファーを受け取る準備ができます。

2.4 暗号技術の知識#

クレデンシャルの発行は、信頼性と真正性を保証するために基本的な暗号概念に依存する、セキュリティ上重要な操作です。

2.4.1 デジタル署名#

検証可能なクレデンシャルの核心は、Issuerによってデジタル署名された一連のクレーム(主張)です。この署名は2つの保証を提供します。

  • 真正性: クレデンシャルが正当なIssuerによって作成されたことを証明します。
  • 完全性: クレデンシャルが発行されてから改ざんされていないことを証明します。

2.4.2 公開鍵/秘密鍵暗号#

デジタル署名は、公開鍵/秘密鍵暗号を使用して作成されます。仕組みは次のとおりです。

  1. Issuerは鍵ペアを持っています:秘密に保管される秘密鍵と、公開される対応する公開鍵です。
  2. 署名: Issuerはクレデンシャルを作成する際に、自身の秘密鍵を使用して、クレデンシャルデータに対する一意のデジタル署名を生成します。
  3. 検証: Verifierは後でIssuerの公開鍵を使用して署名を確認できます。チェックが通れば、Verifierはそのクレデンシャルが本物であり、改ざんされていないことを知ります。

私たちの実装では、楕円曲線(EC)鍵ペアを生成し、ES256アルゴリズムを使用してJWT-VCに署名します。公開鍵はIssuerのDID(did:web)に埋め込まれており、どのVerifierでもそれを発見してクレデンシャルの署名を検証できます。 注: aud(audience)クレームは、クレデンシャルが汎用目的であり、特定のウォレットに縛られないように設計されているため、私たちのJWTでは意図的に省略されています。使用を特定のオーディエンスに制限したい場合は、audクレームを含めて適切に設定してください。

3. アーキテクチャの概要#

私たちのIssuerアプリケーションは、フロントエンドとバックエンドのロジックが明確に分離されたフルスタックのNext.jsプロジェクトとして構築されています。このアーキテクチャにより、サーバー上ですべてのセキュリティ上重要な操作を処理しながら、シームレスなユーザー体験を作り出すことができます。 重要: SQLに含まれるverification_sessionsおよびverified_credentialsテーブルは、このIssuerには不要ですが、完全性のために含まれています。

  • フロントエンド (src/app/issue/page.tsx): ユーザーがクレデンシャルを要求するためにデータを入力できる単一のReactページ。発行プロセスを開始するためにバックエンドAPIを呼び出します。
  • バックエンド API Routes (src/app/api/issue/...): OpenID4VCIプロトコルを実装するサーバーサイドのエンドポイント群。
    • /.well-known/openid-credential-issuer: 公開メタデータエンドポイント。これはウォレットが最初にチェックするURLで、Issuerの機能(認可サーバー、トークンエンドポイント、クレデンシャルエンドポイント、提供するクレデンシャルの種類など)を発見します。
    • /.well-known/openid-configuration: 標準的なOpenID Connectディスカバリーエンドポイント。上記と密接に関連していますが、このエンドポイントはより広範なOIDC関連の設定を提供し、標準的なOpenIDクライアントとの相互運用性のためにしばしば必要とされます。
    • /.well-known/did.json: 私たちのIssuerのDIDドキュメント。did:webメソッドを使用する場合、このファイルはIssuerの公開鍵を公開するために使用され、Verifierはこれを使って発行されたクレデンシャルの署名を検証できます。
    • authorize/route.ts: pre-authorized_codeとクレデンシャルオファーを作成します。
    • token/route.ts: pre-authorized_codeアクセストークンと交換します。
    • credential/route.ts: 最終的な暗号署名付きのJWT-VCを発行します。
    • schemas/pid/route.ts: PIDクレデンシャルのJSONスキーマを公開します。これにより、クレデンシャルの利用者はその構造とデータ型を理解できます。
  • ライブラリ (src/lib/):
    • database.ts: 認可コードやIssuerキーの保存など、すべてのデータベース操作を管理します。
    • crypto.ts: 鍵生成やJWT署名など、すべての暗号操作を処理します。

以下は、発行フローを示す図です。

4. Issuerの構築#

標準、プロトコル、アーキテクチャについてしっかりと理解できたので、Issuerの構築を始めましょう。

一緒に進めるか、完成版コードを使用するか

これからセットアップとコードの実装をステップバイステップで進めていきます。すぐに完成品に進みたい場合は、GitHubリポジトリから完全なプロジェクトをクローンしてローカルで実行できます。

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

4.1 プロジェクトのセットアップ#

まず、新しいNext.jsプロジェクトを初期化し、必要な依存関係をインストールし、データベースを起動します。

4.1.1 Next.jsアプリの初期化#

ターミナルを開き、プロジェクトを作成したいディレクトリに移動して、次のコマンドを実行します。このプロジェクトではApp Router、TypeScript、Tailwind CSSを使用します。

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

このコマンドは、現在のディレクトリに新しいNext.jsアプリケーションを構築します。

4.1.2 依存関係のインストール#

次に、JWT、データベース接続、UUID生成を処理するライブラリをインストールする必要があります。

npm install jose mysql2 uuid @types/uuid

このコマンドでインストールされるもの:

  • jose: JSON Web Token (JWT) の署名と検証のため。
  • mysql2: データベース用のMySQLクライアント。
  • uuid: 一意のチャレンジ文字列を生成するため。
  • @types/uuid: uuidライブラリのTypeScript型定義。

4.1.3 データベースの起動#

バックエンドは、認可コード、発行セッション、Issuerキーを保存するために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という名前のファイルを作成して、Verifierと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) );

両方のファイルが配置されたら、プロジェクトのルートでターミナルを開き、実行します。

docker-compose up -d

このコマンドは、バックグラウンドでMySQLコンテナを起動し、アプリケーションが使用できる状態にします。

4.2 共通ライブラリの実装#

APIエンドポイントを構築する前に、コアなビジネスロジックを処理する共有ライブラリを作成しましょう。このアプローチにより、APIルートはHTTPリクエストの処理に集中し、クリーンな状態を保つことができ、複雑な作業はこれらのモジュールに委任されます。

4.2.1 データベースライブラリ (src/lib/database.ts)#

このファイルは、すべてのデータベース操作の唯一の情報源です。mysql2ライブラリを使用してMySQLコンテナに接続し、テーブルのレコードを作成、読み取り、更新するための一連のエクスポートされた関数を提供します。この抽象化レイヤーにより、コードはよりモジュール化され、保守が容易になります。

src/lib/database.tsファイルを作成し、以下の内容を記述します。

// 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.)

注: 簡潔にするため、DAO関数の完全なリストは省略されています。完全なコードはプロジェクトリポジトリで確認できます。このファイルには、チャレンジ、検証セッション、認可コード、発行セッション、Issuerキーを管理するための関数が含まれています。

4.2.2 暗号ライブラリ (src/lib/crypto.ts)#

このファイルは、セキュリティ上重要なすべての暗号操作を処理します。joseライブラリを使用して、鍵ペアを生成し、JSON Web Token (JWT) に署名します。

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

JWTクレデンシャルの作成 createJWTVerifiableCredential関数は、発行プロセスの中心です。ユーザーのクレーム、Issuerの鍵ペア、その他のメタデータを受け取り、それらを使用して署名付きの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 = { // 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); }

この関数は、W3C検証可能クレデンシャルデータモデルに従ってJWTペイロードを構築し、Issuerの秘密鍵で署名して、安全で検証可能なクレデンシャルを生成します。

4.2 Next.jsアプリのアーキテクチャ概要#

私たちのNext.jsアプリケーションは、同じプロジェクトの一部でありながら、フロントエンドとバックエンドの関心事を分離するように構成されています。これは、UIページとAPIエンドポイントの両方にApp Routerを活用することで実現されます。

  • フロントエンド (src/app/issue/page.tsx): /issueルートのUIを定義する単一のReactページコンポーネント。ユーザー入力を処理し、バックエンドAPIと通信します。

  • バックエンド API Routes (src/app/api/...):

    • ディスカバリー (.well-known/.../route.ts): これらのルートは、ウォレットや他のクライアントがIssuerの機能や公開鍵を発見できるようにする公開メタデータエンドポイントを公開します。
    • 発行 (issue/.../route.ts): これらのエンドポイントは、クレデンシャルオファーの作成、トークンの発行、最終的なクレデンシャルの署名など、コアとなるOpenID4VCIロジックを実装します。
    • スキーマ (schemas/pid/route.ts): このルートは、クレデンシャルのJSONスキーマを提供し、その構造を定義します。
  • ライブラリ (src/lib/): このディレクトリには、バックエンド全体で共有される再利用可能なロジックが含まれています。

    • database.ts: SQLクエリを抽象化し、すべてのデータベース操作を管理します。
    • crypto.ts: 鍵生成やJWT署名など、すべての暗号操作を処理します。

この明確な分離により、アプリケーションはモジュール化され、保守が容易になります。

注: generateIssuerDid()関数は、Issuerのドメインに一致する有効なdid:webを返す必要があります。デプロイ時には、Verifierがクレデンシャルを検証できるように、.well-known/did.jsonはそのドメインでHTTPS経由で提供される必要があります。

4.3 フロントエンドの構築#

フロントエンドは、ユーザーが新しいデジタルクレデンシャルを要求するためのシンプルなフォームを提供する単一のReactページです。その責務は以下の通りです。

  • ユーザーデータ(名前、生年月日など)をキャプチャする。
  • このデータをバックエンドに送信してクレデンシャルオファーを作成する。
  • 結果として得られたQRコードとPINをユーザーがウォレットでスキャンできるように表示する。

コアロジックは、ユーザーがフォームを送信したときにトリガーされるhandleSubmit関数で処理されます。

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

この関数は3つの主要なアクションを実行します。

  1. フォームデータの検証:すべての必須フィールドが入力されていることを確認します。
  2. POSTリクエストの送信:ユーザーデータとともに/api/issue/authorizeエンドポイントにリクエストを送信します。
  3. コンポーネントの状態を更新:バックエンドから受け取ったクレデンシャルオファーで状態を更新し、UIがQRコードとトランザクションコードを表示するようにトリガーします。

ファイルの残りの部分は、フォームとQRコード表示をレンダリングするための標準的なReactコードです。完全なファイルはプロジェクトリポジトリで確認できます。

4.4 環境とディスカバリーの設定#

バックエンドAPIを構築する前に、環境を設定し、ディスカバリーエンドポイントをセットアップする必要があります。これらの.well-knownファイルは、ウォレットが私たちのIssuerを見つけ、対話方法を理解するために不可欠です。

4.4.1 環境ファイルの作成#

プロジェクトのルートに.env.localという名前のファイルを作成し、次の行を追加します。このURLは、モバイルウォレットがアクセスできるように公開されている必要があります。ローカル開発では、ngrokのようなトンネリングサービスを使用してlocalhostを公開できます。

NEXT_PUBLIC_BASE_URL=http://localhost:3000

4.4.2 ディスカバリーエンドポイントの実装#

ウォレットは、標準の.well-knownURLをクエリすることで、Issuerの機能を発見します。これらのエンドポイントを3つ作成する必要があります。

1. Issuerメタデータ (/.well-known/openid-credential-issuer)

これはOpenID4VCIの主要なディスカバリーファイルです。Issuerのエンドポイント、提供するクレデンシャルの種類、サポートされている暗号アルゴリズムなど、ウォレットが必要とするすべての情報を伝えます。

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. 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 = { // 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. DIDドキュメント (/.well-known/did.json)

このファイルは、did:webメソッドを使用してIssuerの公開鍵を公開し、誰でもそれによって発行されたクレデンシャルの署名を検証できるようにします。

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

なぜキャッシュしないのか? これら3つのエンドポイントがすべて、積極的にキャッシュを防ぐヘッダー(Cache-Control: no-cachePragma: no-cacheExpires: 0)を返していることにお気づきでしょう。これはディスカバリー文書にとって重要なセキュリティプラクティスです。Issuerの設定は変更される可能性があります。例えば、暗号鍵がローテーションされるかもしれません。ウォレットやクライアントが古いバージョンのdid.jsonopenid-credential-issuerファイルをキャッシュしてしまうと、新しいクレデンシャルの検証や更新されたエンドポイントとの対話に失敗してしまいます。クライアントに各リクエストで新しいコピーを取得させることで、常に最新の情報を持っていることを保証します。

4.4.3 クレデンシャルスキーマエンドポイントの実装#

公開インフラの最後のピースは、クレデンシャルスキーマエンドポイントです。このルートは、私たちが発行しているPIDクレデンシャルの構造、データ型、制約を正式に定義するJSONスキーマを提供します。ウォレットやVerifierは、このスキーマを使用してクレデンシャルの内容を検証できます。

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

注: PIDクレデンシャルのJSONスキーマは非常に大きく詳細になることがあります。簡潔にするため、完全なスキーマは省略されています。完全なファイルはプロジェクトリポジトリで確認できます。

4.5 バックエンドエンドポイントの構築#

フロントエンドができたので、次にOpenID4VCIフローを処理するサーバーサイドのロジックが必要です。まず、フロントエンドが最初に呼び出すエンドポイントである/api/issue/authorizeから始めましょう。

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

このエンドポイントの主要なステップ:

  1. データの検証: 最初に必要なユーザーデータが存在することを確認します。
  2. コードの生成: 一意のpre-authorized_code(UUID)と、追加のセキュリティ層のための4桁のtx_code(PIN)を作成します。
  3. データの永続化: pre-authorized_codeは短い有効期限付きでデータベースに保存されます。ユーザーデータとPINは、コードにリンクされてメモリ内に保存されます。
  4. オファーの構築: OpenID4VCI仕様に従ってcredential_offerオブジェクトを構築します。このオブジェクトは、ウォレットにIssuerの場所、提供するクレデンシャル、そしてそれらを取得するために必要なコードを伝えます。
  5. URIの返却: 最後に、ディープリンクURI(openid-credential-offer://...)を作成し、ユーザーが見るためのtx_codeとともにフロントエンドに返します。

4.5.2 /api/issue/token: コードをトークンに交換#

ユーザーがQRコードをスキャンしてPINを入力すると、ウォレットはこのエンドポイントにPOSTリクエストを送信します。その仕事は、pre-authorized_codeuser_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. 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 }); } }

このエンドポイントの主要なステップ:

  1. グラントタイプの検証: ウォレットが正しいpre-authorized_codeグラントタイプを使用していることを確認します。
  2. コードの検証: pre-authorized_codeがデータベースに存在し、有効期限が切れておらず、まだ使用されていないことを確認します。
  3. PINの検証: ウォレットからのuser_pinを、以前に保存したtx_codeと比較して、ユーザーがトランザクションを承認したことを確認します。
  4. トークンの生成: 安全なaccess_tokenと、クレデンシャルエンドポイントへのリプレイ攻撃を防ぐためのワンタイム値であるc_nonce(クレデンシャルノンス)を作成します。
  5. セッションの作成: データベースに新しいissuance_sessionsレコードを作成し、アクセストークンをユーザーのデータにリンクします。
  6. コードを使用済みにマーク: 同じオファーが二度使用されるのを防ぐために、pre-authorized_codeを使用済みとしてマークします。
  7. トークンの返却: access_tokenc_nonceをウォレットに返します。

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

このエンドポイントの主要なステップ:

  1. トークンの検証: Authorizationヘッダーに有効なBearerトークンがあるかを確認し、それを使用してアクティブな発行セッションを検索します。
  2. ユーザーデータの取得: トークンが作成されたときにセッションに保存されたユーザーのクレームデータを取得します。
  3. Issuerキーの読み込み: Issuerのアクティブな署名キーをデータベースから読み込みます。実際のシナリオでは、これは安全な鍵管理システムによって管理されます。
  4. クレデンシャルの作成: src/lib/crypto.tscreateJWTVerifiableCredentialヘルパーを呼び出して、JWT-VCを構築し、署名します。
  5. 発行のログ記録: 監査および失効目的のために、発行されたクレデンシャルの記録をデータベースに保存します。
  6. クレデンシャルの返却: 署名されたクレデンシャルをJSONレスポンスでウォレットに返します。その後、ウォレットはそれを安全に保存する責任を負います。

5. Issuerの実行と次のステップ#

これで、デジタルクレデンシャルIssuerの完全なエンドツーエンドの実装が完成しました。これをローカルで実行する方法と、概念実証から本番対応のアプリケーションに移行するために考慮すべき点について説明します。

5.1 サンプルの実行方法#

  1. リポジトリのクローン:

    git clone https://github.com/corbado/digital-credentials-example.git cd digital-credentials-example
  2. 依存関係のインストール:

    npm install
  3. データベースの起動: Dockerが実行されていることを確認し、MySQLコンテナを起動します。

    docker-compose up -d
  4. 環境設定とトンネルの実行: これはローカルテストで最も重要なステップです。モバイルウォレットはインターネット経由で開発マシンに接続する必要があるため、ローカルサーバーを公開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>
  5. アプリケーションの実行:

    npm run dev

    ブラウザでhttp://localhost:3000/issueを開きます。フォームに入力すると、生成されたQRコードは公開ngrok URLを正しく指し示し、モバイルウォレットが接続してクレデンシャルを受け取ることができます。

5.2 HTTPSとngrokの重要性#

デジタルクレデンシャルプロトコルは、セキュリティを最優先に構築されています。このため、ウォレットはほとんどの場合、安全でない(http://)接続を介してIssuerに接続することを拒否します。プロセス全体は、SSL証明書によって有効化される安全なHTTPS接続に依存しています。

ngrokのようなトンネルサービスは、すべてのトラフィックをローカル開発サーバーに転送する、公開された安全なHTTPS URL(有効なSSL証明書付き)を作成することで、両方の問題を解決します。ウォレットはHTTPSを要求し、安全でない(http://)エンドポイントへの接続を拒否します。これは、モバイルデバイスや外部のwebhookと対話する必要があるWebサービスをテストするための不可欠なツールです。

5.3 このチュートリアルの対象外#

このサンプルは、理解を容易にするために、意図的にコアな発行フローに焦点を当てています。以下のトピックは対象外と見なされます。

  • 本番レベルのセキュリティ: このIssuerは教育目的です。本番システムでは、データベースにキーを保存する代わりに安全な鍵管理システム(KMS)、堅牢なエラーハンドリング、レート制限、包括的な監査ログが必要です。
  • クレデンシャルの失効: このガイドでは、発行されたクレデンシャルを失効させるメカニズムは実装していません。スキーマには将来の使用のためにrevokedフラグが含まれていますが、失効ロジックは提供されていません。
  • 認可コードフロー: pre-authorized_codeフローにのみ焦点を当てました。authorization_codeフローの完全な実装には、ユーザーの同意画面とより複雑なOAuth 2.0ロジックが必要です。
  • ユーザー管理: このガイドには、Issuer自体のユーザー認証や管理は含まれていません。ユーザーは既に認証され、クレデンシャルを受け取る権限があると想定されています。

6. まとめ#

以上です!数ページのコードで、以下の機能を持つ完全なエンドツーエンドのデジタルクレデンシャルIssuerが完成しました。

  1. クレデンシャルを要求するためのユーザーフレンドリーなフロントエンドを提供する。
  2. 完全なOpenID4VCI pre-authorized_codeフローを実装する。
  3. ウォレットの相互運用性のために必要なすべてのディスカバリーエンドポイントを公開する。
  4. 安全で標準に準拠したJWT-検証可能なクレデンシャルを生成し、署名する。

このガイドは堅固な基盤を提供しますが、本番対応のIssuerには、堅牢な鍵管理、インメモリストアの代わりに永続ストレージ、クレデンシャルの失効、包括的なセキュリティ強化などの追加機能が必要です。また、ウォレットの互換性も様々です。テストにはSphereon Walletが推奨されますが、他のウォレットではここで実装されている事前承認フローがサポートされていない場合があります。しかし、コアとなる構成要素とインタラクションフローは同じです。これらのパターンに従うことで、あらゆる種類のデジタルクレデンシャルに対して安全で相互運用可能なIssuerを構築できます。

7. リソース#

このチュートリアルで使用または参照された主要なリソース、仕様、ツールを以下に示します。

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

Start Free Trial

Share this article


LinkedInTwitterFacebook

Table of Contents