OpenID4VCIプロトコルを使用してW3C検証可能なクレデンシャル発行者を構築する方法を学びましょう。このステップバイステップガイドでは、デジタルウォレットと互換性のある暗号署名付きクレデンシャルを発行するNext.jsアプリケーションの作成方法を解説します。
Amine
Created: August 20, 2025
Updated: August 21, 2025
See the original blog version in English here.
デジタルクレデンシャルは、安全でプライバシーを保護する方法で身元や主張を証明するための強力な手段です。しかし、ユーザーはそもそもどのようにしてこれらのクレデンシャルを入手するのでしょうか?ここで重要になるのがIssuerの役割です。Issuerとは、政府機関、大学、銀行などの信頼できる事業体で、デジタル署名されたクレデンシャルを作成し、ユーザーに配布する責任を担います。
このガイドでは、デジタルクレデンシャルIssuerを構築するための包括的なステップバイステップのチュートリアルを提供します。今回は、ユーザーがIssuerからクレデンシャルを取得し、自身のデジタルウォレットに安全に保管する方法を定義する最新の標準である**OpenID for Verifiable Credential Issuance (OpenID4VCI)**プロトコルに焦点を当てます。
最終的に、以下の機能を持つNext.jsアプリケーションが完成します。
先に進む前に、関連しているものの異なる2つの概念の違いを明確にしておくことが重要です。
デジタルクレデンシャル(一般用語): これは、クレデンシャル、証明書、または構成証明のあらゆるデジタル形式を包括する広範なカテゴリです。これらには、単純なデジタル証明書、基本的なデジタルバッジ、または暗号化によるセキュリティ機能を持つ場合も持たない場合もある電子的に保存されたクレデンシャルが含まれます。
検証可能なクレデンシャル(VCs - W3C標準): これは、W3C Verifiable Credentials Data Model標準に準拠した特定のタイプのデジタルクレデンシャルです。検証可能なクレデンシャルは、暗号署名され、改ざんが検知可能で、プライバシーを尊重するクレデンシャルであり、独立して検証できます。次のような特定の技術的要件が含まれます。
**このガイドでは、単なるデジタルクレデンシャルシステムではなく、W3C標準に準拠した検証可能なクレデンシャル発行者を具体的に構築します。**私たちが使用するOpenID4VCIプロトコルは、検証可能なクレデンシャルの発行専用に設計されており、実装するJWT-VCフォーマットは、検証可能なクレデンシャルのためのW3C準拠のフォーマットです。
デジタルクレデンシャルの背後にある魔法は、3つの主要なプレイヤーが関わるシンプルかつ強力な「信頼の三角形」モデルにあります。
発行フローは、このエコシステムの最初のステップです。Issuerはユーザーの情報を検証し、クレデンシャルを提供します。Holderがこのクレデンシャルをウォレットに持つと、Verifierに提示して身元や主張を証明し、三角形が完成します。
最終的なアプリケーションの動作を簡単に見てみましょう。
ステップ1:ユーザーデータの入力 ユーザーは、新しいクレデンシャルをリクエストするために個人情報をフォームに入力します。
ステップ2:クレデンシャルオファーの生成 アプリケーションは、安全なクレデンシャルオファーを生成し、QRコードと事前承認コードとして表示します。
ステップ3:ウォレットとの対話 ユーザーは、互換性のあるウォレット(例:Sphereon Wallet)でQRコードをスキャンし、PINを入力して発行を承認します。
ステップ4:クレデンシャルの発行 ウォレットは、新しく発行されたデジタルクレデンシャルを受け取り、将来の使用に備えて保管します。
コードに入る前に、必要となる基礎知識とツールについて説明します。このガイドは、Web開発の基本的な概念に精通していることを前提としていますが、クレデンシャル発行者を構築するためには以下の前提条件が不可欠です。
私たちの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.0 | OpenID4VCIが使用する基盤となる認可フレームワークです。私たちはpre-authorized_code フローを実装します。これは、安全でユーザーフレンドリーなクレデンシャル発行のために設計された特定のグラントタイプです。 |
OpenID4VCIは、クレデンシャルを発行するための2つの主要な認可フローをサポートしています。
事前承認コードフロー(Pre-Authorized Code Flow):
このフローでは、Issuerは短命で一度しか使えないコード(pre-authorized_code
)を生成し、すぐにユーザーに提供します。ユーザーのウォレットはこのコードを直接クレデンシャルと交換できます。このフローは、ユーザーが既にIssuerのウェブサイトで認証され、その場にいるシナリオに最適です。リダイレクトなしでシームレスかつ即時の発行体験を提供します。
認可コードフロー(Authorization Code Flow):
これは標準的なOAuth 2.0フローで、ユーザーは同意を与えるために認可サーバーにリダイレクトされます。承認後、サーバーは登録されたredirect_uri
にauthorization_code
を送り返します。このフローは、ユーザーに代わって発行プロセスを開始するサードパーティアプリケーションに適しています。
**このチュートリアルでは、pre-authorized_code
フローを使用します。**このアプローチを選択したのは、よりシンプルで、私たちの特定のユースケース(ユーザーがIssuerのウェブサイトから直接クレデンシャルを要求する)に対してより直接的なユーザー体験を提供するためです。複雑なリダイレクトやクライアント登録の必要がなくなり、コアとなる発行ロジックをより理解しやすく、実装しやすくなります。
この標準の組み合わせにより、私たちは幅広いデジタルウォレットと互換性があり、ユーザーにとって安全で標準化されたプロセスを保証するIssuerを構築することができます。
Issuerを構築するために、Verifierで使用したものと同じ堅牢でモダンな技術スタックを使用し、一貫性のある高品質な開発者体験を保証します。
フロントエンドとバックエンドの両方のコードにTypeScriptを使用します。その静的型付けは、Issuerのようなセキュリティが重要なアプリケーションにおいて非常に価値があり、一般的なエラーを防ぎ、コード全体の品質と保守性を向上させるのに役立ちます。
Next.jsは、フルスタックアプリケーションを構築するためのシームレスで統合された体験を提供するため、私たちが選んだフレームワークです。
私たちの実装は、特定のタスクを処理するためにいくつかの主要なライブラリに依存します。
pre-authorized_code
の値を作成するために使用します。Issuerをテストするには、OpenID4VCIプロトコルをサポートするモバイルウォレットが必要です。このチュートリアルでは、AndroidとiOSの両方で利用可能なSphereon Walletをお勧めします。
Sphereon Walletのインストール方法:
クレデンシャルの発行は、信頼性と真正性を保証するために基本的な暗号概念に依存する、セキュリティ上重要な操作です。
検証可能なクレデンシャルの核心は、Issuerによってデジタル署名された一連のクレーム(主張)です。この署名は2つの保証を提供します。
デジタル署名は、公開鍵/秘密鍵暗号を使用して作成されます。仕組みは次のとおりです。
私たちの実装では、楕円曲線(EC)鍵ペアを生成し、ES256
アルゴリズムを使用してJWT-VCに署名します。公開鍵はIssuerのDID(did:web
)に埋め込まれており、どのVerifierでもそれを発見してクレデンシャルの署名を検証できます。
注:
aud
(audience)クレームは、クレデンシャルが汎用目的であり、特定のウォレットに縛られないように設計されているため、私たちのJWTでは意図的に省略されています。使用を特定のオーディエンスに制限したい場合は、aud
クレームを含めて適切に設定してください。
私たちのIssuerアプリケーションは、フロントエンドとバックエンドのロジックが明確に分離されたフルスタックのNext.jsプロジェクトとして構築されています。このアーキテクチャにより、サーバー上ですべてのセキュリティ上重要な操作を処理しながら、シームレスなユーザー体験を作り出すことができます。
重要:
SQLに含まれるverification_sessions
およびverified_credentials
テーブルは、このIssuerには不要ですが、完全性のために含まれています。
src/app/issue/page.tsx
):
ユーザーがクレデンシャルを要求するためにデータを入力できる単一のReactページ。発行プロセスを開始するためにバックエンドAPIを呼び出します。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署名など、すべての暗号操作を処理します。以下は、発行フローを示す図です。
標準、プロトコル、アーキテクチャについてしっかりと理解できたので、Issuerの構築を始めましょう。
一緒に進めるか、完成版コードを使用するか
これからセットアップとコードの実装をステップバイステップで進めていきます。すぐに完成品に進みたい場合は、GitHubリポジトリから完全なプロジェクトをクローンしてローカルで実行できます。
git clone https://github.com/corbado/digital-credentials-example.git
まず、新しいNext.jsプロジェクトを初期化し、必要な依存関係をインストールし、データベースを起動します。
ターミナルを開き、プロジェクトを作成したいディレクトリに移動して、次のコマンドを実行します。このプロジェクトではApp Router、TypeScript、Tailwind CSSを使用します。
npx create-next-app@latest . --ts --eslint --tailwind --app --src-dir --import-alias "@/*" --use-npm
このコマンドは、現在のディレクトリに新しいNext.jsアプリケーションを構築します。
次に、JWT、データベース接続、UUID生成を処理するライブラリをインストールする必要があります。
npm install jose mysql2 uuid @types/uuid
このコマンドでインストールされるもの:
jose
: JSON Web Token (JWT) の署名と検証のため。mysql2
: データベース用のMySQLクライアント。uuid
: 一意のチャレンジ文字列を生成するため。@types/uuid
: uuid
ライブラリのTypeScript型定義。バックエンドは、認可コード、発行セッション、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コンテナを起動し、アプリケーションが使用できる状態にします。
APIエンドポイントを構築する前に、コアなビジネスロジックを処理する共有ライブラリを作成しましょう。このアプローチにより、APIルートはHTTPリクエストの処理に集中し、クリーンな状態を保つことができ、複雑な作業はこれらのモジュールに委任されます。
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キーを管理するための関数が含まれています。
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の秘密鍵で署名して、安全で検証可能なクレデンシャルを生成します。
私たちの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経由で提供される必要があります。
フロントエンドは、ユーザーが新しいデジタルクレデンシャルを要求するためのシンプルなフォームを提供する単一のReactページです。その責務は以下の通りです。
コアロジックは、ユーザーがフォームを送信したときにトリガーされる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つの主要なアクションを実行します。
POST
リクエストの送信:ユーザーデータとともに/api/issue/authorize
エンドポイントにリクエストを送信します。ファイルの残りの部分は、フォームとQRコード表示をレンダリングするための標準的なReactコードです。完全なファイルはプロジェクトリポジトリで確認できます。
バックエンドAPIを構築する前に、環境を設定し、ディスカバリーエンドポイントをセットアップする必要があります。これらの.well-known
ファイルは、ウォレットが私たちのIssuerを見つけ、対話方法を理解するために不可欠です。
プロジェクトのルートに.env.local
という名前のファイルを作成し、次の行を追加します。このURLは、モバイルウォレットがアクセスできるように公開されている必要があります。ローカル開発では、ngrokのようなトンネリングサービスを使用してlocalhost
を公開できます。
NEXT_PUBLIC_BASE_URL=http://localhost:3000
ウォレットは、標準の.well-known
URLをクエリすることで、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-cache
、Pragma: no-cache
、Expires: 0
)を返していることにお気づきでしょう。これはディスカバリー文書にとって重要なセキュリティプラクティスです。Issuerの設定は変更される可能性があります。例えば、暗号鍵がローテーションされるかもしれません。ウォレットやクライアントが古いバージョンのdid.json
やopenid-credential-issuer
ファイルをキャッシュしてしまうと、新しいクレデンシャルの検証や更新されたエンドポイントとの対話に失敗してしまいます。クライアントに各リクエストで新しいコピーを取得させることで、常に最新の情報を持っていることを保証します。
公開インフラの最後のピースは、クレデンシャルスキーマエンドポイントです。このルートは、私たちが発行している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スキーマは非常に大きく詳細になることがあります。簡潔にするため、完全なスキーマは省略されています。完全なファイルはプロジェクトリポジトリで確認できます。
フロントエンドができたので、次にOpenID4VCIフローを処理するサーバーサイドのロジックが必要です。まず、フロントエンドが最初に呼び出すエンドポイントである/api/issue/authorize
から始めましょう。
/api/issue/authorize
: クレデンシャルオファーの作成#このエンドポイントは、ユーザーのデータを受け取り、安全なワンタイムユースコードを生成し、ユーザーのウォレットが理解できるcredential_offer
を構築する責任があります。
これがコアロジックです。
// src/app/api/issue/authorize/route.ts import { NextRequest, NextResponse } from "next/server"; import { v4 as uuidv4 } from "uuid"; import { createAuthorizationCode } from "@/lib/database"; export async function POST(request: NextRequest) { try { const body = await request.json(); const { user_data } = body; // 1. 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 }); } }
このエンドポイントの主要なステップ:
pre-authorized_code
(UUID)と、追加のセキュリティ層のための4桁のtx_code
(PIN)を作成します。pre-authorized_code
は短い有効期限付きでデータベースに保存されます。ユーザーデータとPINは、コードにリンクされてメモリ内に保存されます。credential_offer
オブジェクトを構築します。このオブジェクトは、ウォレットにIssuerの場所、提供するクレデンシャル、そしてそれらを取得するために必要なコードを伝えます。openid-credential-offer://...
)を作成し、ユーザーが見るためのtx_code
とともにフロントエンドに返します。/api/issue/token
: コードをトークンに交換#ユーザーがQRコードをスキャンしてPINを入力すると、ウォレットはこのエンドポイントにPOST
リクエストを送信します。その仕事は、pre-authorized_code
とuser_pin
(PIN)を検証し、それらが有効であれば、短命のアクセストークンを発行することです。
src/app/api/issue/token/route.ts
ファイルに以下の内容を記述して作成します。
// src/app/api/issue/token/route.ts import { NextRequest, NextResponse } from "next/server"; import { v4 as uuidv4 } from "uuid"; import { getAuthorizationCode, markAuthorizationCodeAsUsed, createIssuanceSession, } from "@/lib/database"; export async function POST(request: NextRequest) { try { const formData = await request.formData(); const grant_type = formData.get("grant_type") as string; const code = formData.get("pre-authorized_code") as string; const user_pin = formData.get("user_pin") as string; // 1. 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 }); } }
このエンドポイントの主要なステップ:
pre-authorized_code
グラントタイプを使用していることを確認します。pre-authorized_code
がデータベースに存在し、有効期限が切れておらず、まだ使用されていないことを確認します。user_pin
を、以前に保存したtx_code
と比較して、ユーザーがトランザクションを承認したことを確認します。access_token
と、クレデンシャルエンドポイントへのリプレイ攻撃を防ぐためのワンタイム値であるc_nonce
(クレデンシャルノンス)を作成します。issuance_sessions
レコードを作成し、アクセストークンをユーザーのデータにリンクします。pre-authorized_code
を使用済みとしてマークします。access_token
とc_nonce
をウォレットに返します。/api/issue/credential
: 署名済みクレデンシャルの発行#これが最後の、そして最も重要なエンドポイントです。ウォレットは/token
エンドポイントから受け取ったアクセストークンを使用して、このルートに認証済みのPOST
リクエストを送信します。このエンドポイントの仕事は、最終的な検証を行い、暗号署名されたクレデンシャルを作成し、それをウォレットに返すことです。
src/app/api/issue/credential/route.ts
ファイルに以下の内容を記述して作成します。
// src/app/api/issue/credential/route.ts import { NextRequest, NextResponse } from "next/server"; import { v4 as uuidv4 } from "uuid"; import { getIssuanceSessionByToken, updateIssuanceSession, createIssuedCredential, getActiveIssuerKey, } from "@/lib/database"; import { createJWTVerifiableCredential, importIssuerKeyPair, generateIssuerDid, } from "@/lib/crypto"; export async function POST(request: NextRequest) { try { // 1. 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 }); } }
このエンドポイントの主要なステップ:
Authorization
ヘッダーに有効なBearer
トークンがあるかを確認し、それを使用してアクティブな発行セッションを検索します。src/lib/crypto.ts
のcreateJWTVerifiableCredential
ヘルパーを呼び出して、JWT-VCを構築し、署名します。これで、デジタルクレデンシャルIssuerの完全なエンドツーエンドの実装が完成しました。これをローカルで実行する方法と、概念実証から本番対応のアプリケーションに移行するために考慮すべき点について説明します。
リポジトリのクローン:
git clone https://github.com/corbado/digital-credentials-example.git cd digital-credentials-example
依存関係のインストール:
npm install
データベースの起動: Dockerが実行されていることを確認し、MySQLコンテナを起動します。
docker-compose up -d
環境設定とトンネルの実行:
これはローカルテストで最も重要なステップです。モバイルウォレットはインターネット経由で開発マシンに接続する必要があるため、ローカルサーバーを公開HTTPS
URLで公開する必要があります。ここではngrok
を使用します。
a. ngrokの起動:
ngrok http 3000
b. ngrokの出力からHTTPS
URLをコピーします(例:https://random-string.ngrok.io
)。c.
.env.local
ファイルを作成し、URLを設定します。
NEXT_PUBLIC_BASE_URL=https://<your-ngrok-url>
アプリケーションの実行:
npm run dev
ブラウザでhttp://localhost:3000/issue
を開きます。フォームに入力すると、生成されたQRコードは公開ngrok
URLを正しく指し示し、モバイルウォレットが接続してクレデンシャルを受け取ることができます。
ngrok
の重要性#デジタルクレデンシャルプロトコルは、セキュリティを最優先に構築されています。このため、ウォレットはほとんどの場合、安全でない(http://
)接続を介してIssuerに接続することを拒否します。プロセス全体は、SSL証明書によって有効化される安全なHTTPS接続に依存しています。
ngrok
のようなトンネルサービスは、すべてのトラフィックをローカル開発サーバーに転送する、公開された安全なHTTPS
URL(有効なSSL証明書付き)を作成することで、両方の問題を解決します。ウォレットはHTTPSを要求し、安全でない(http://
)エンドポイントへの接続を拒否します。これは、モバイルデバイスや外部のwebhookと対話する必要があるWebサービスをテストするための不可欠なツールです。
このサンプルは、理解を容易にするために、意図的にコアな発行フローに焦点を当てています。以下のトピックは対象外と見なされます。
revoked
フラグが含まれていますが、失効ロジックは提供されていません。pre-authorized_code
フローにのみ焦点を当てました。authorization_code
フローの完全な実装には、ユーザーの同意画面とより複雑なOAuth 2.0ロジックが必要です。以上です!数ページのコードで、以下の機能を持つ完全なエンドツーエンドのデジタルクレデンシャルIssuerが完成しました。
pre-authorized_code
フローを実装する。このガイドは堅固な基盤を提供しますが、本番対応のIssuerには、堅牢な鍵管理、インメモリストアの代わりに永続ストレージ、クレデンシャルの失効、包括的なセキュリティ強化などの追加機能が必要です。また、ウォレットの互換性も様々です。テストにはSphereon Walletが推奨されますが、他のウォレットではここで実装されている事前承認フローがサポートされていない場合があります。しかし、コアとなる構成要素とインタラクションフローは同じです。これらのパターンに従うことで、あらゆる種類のデジタルクレデンシャルに対して安全で相互運用可能なIssuerを構築できます。
このチュートリアルで使用または参照された主要なリソース、仕様、ツールを以下に示します。
プロジェクトリポジトリ:
主要な仕様:
did:web
Method: 私たちのIssuerの公開鍵に使用されるDIDメソッド。ツール:
ライブラリ:
Related Articles
Table of Contents