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

Wie man einen Issuer für digitale Nachweise erstellt (Entwickler-Guide)

Lernen Sie, wie man einen Issuer für W3C Verifiable Credentials mit dem OpenID4VCI-Protokoll erstellt. Diese Schritt-für-Schritt-Anleitung zeigt, wie eine Next.js-Anwendung entwickelt wird, die kryptografisch signierte Nachweise ausstellt, die mit digital

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. Einführung#

Digitale Nachweise sind eine leistungsstarke Methode, um Identität und Ansprüche auf eine sichere und datenschutzfreundliche Weise zu belegen. Aber wie erhalten Nutzer diese Nachweise überhaupt? Hier wird die Rolle des Issuers entscheidend. Ein Issuer ist eine vertrauenswürdige Instanz – wie zum Beispiel eine Regierungsbehörde, eine Universität oder eine Bank –, die für die Erstellung und Verteilung von digital signierten Nachweisen an Nutzer verantwortlich ist.

Dieser Guide bietet eine umfassende Schritt-für-Schritt-Anleitung zur Erstellung eines Issuers für digitale Nachweise. Wir konzentrieren uns auf das Protokoll OpenID for Verifiable Credential Issuance (OpenID4VCI), einen modernen Standard, der festlegt, wie Nutzer Nachweise von einem Issuer erhalten und sicher in ihren digitalen Wallets speichern können.

Das Endergebnis wird eine funktionierende Next.js-Anwendung sein, die Folgendes kann:

  1. Nutzerdaten über ein einfaches Webformular entgegennehmen.
  2. Ein sicheres, einmaliges Credential-Angebot erstellen.
  3. Das Angebot als QR-Code anzeigen, den der Nutzer mit seiner mobilen Wallet scannen kann.
  4. Einen kryptografisch signierten Nachweis ausstellen, den der Nutzer speichern und zur Verifizierung vorlegen kann.

1.1 Terminologie: Digitale Nachweise vs. Verifiable Credentials#

Bevor wir fortfahren, ist es wichtig, den Unterschied zwischen zwei verwandten, aber unterschiedlichen Konzepten zu klären:

  • Digitale Nachweise (allgemeiner Begriff): Dies ist eine breite Kategorie, die jede digitale Form von Nachweisen, Zertifikaten oder Bestätigungen (Attestations) umfasst. Dazu können einfache digitale Zertifikate, grundlegende digitale Badges oder jeder elektronisch gespeicherte Nachweis gehören, der kryptografische Sicherheitsmerkmale haben kann oder auch nicht.

  • Verifiable Credentials (VCs – W3C-Standard): Dies ist ein spezifischer Typ digitaler Nachweise, der dem W3C-Standard für das „Verifiable Credentials Data Model“ folgt. Verifiable Credentials sind kryptografisch signierte, manipulationssichere und datenschutzfreundliche Nachweise, die unabhängig überprüft werden können. Sie beinhalten spezifische technische Anforderungen wie:

    • Kryptografische Signaturen für Authentizität und Integrität
    • Standardisiertes Datenmodell und Formate
    • Datenschutzfreundliche Präsentationsmechanismen
    • Interoperable Verifizierungsprotokolle

In diesem Guide erstellen wir speziell einen Issuer für Verifiable Credentials, der dem W3C-Standard folgt, nicht nur ein beliebiges System für digitale Nachweise. Das von uns verwendete OpenID4VCI-Protokoll ist speziell für die Ausstellung von Verifiable Credentials konzipiert, und das JWT-VC-Format, das wir implementieren werden, ist ein W3C-konformes Format für Verifiable Credentials.

1.2 Wie es funktioniert#

Die Magie hinter digitalen Nachweisen liegt in einem einfachen, aber leistungsstarken „Vertrauensdreieck“-Modell mit drei Hauptakteuren:

  • Issuer: Eine vertrauenswürdige Instanz (z. B. eine Regierungsbehörde, Universität oder Bank), die einen Nachweis kryptografisch signiert und an einen Nutzer ausstellt. Das ist die Rolle, die wir in diesem Guide erstellen.
  • Holder: Der Nutzer, der den Nachweis erhält und ihn sicher in einer persönlichen digitalen Wallet auf seinem Gerät speichert.
  • Verifier: Eine Anwendung oder ein Dienst, der den Nachweis des Nutzers überprüfen muss.

Der Ausstellungsprozess ist der erste Schritt in diesem Ökosystem. Der Issuer validiert die Informationen des Nutzers und stellt ihm einen Nachweis zur Verfügung. Sobald der Holder diesen Nachweis in seiner Wallet hat, kann er ihn einem Verifier vorlegen, um seine Identität oder Ansprüche nachzuweisen, was das Dreieck schließt.

Hier ist ein kurzer Blick auf die fertige Anwendung in Aktion:

Schritt 1: Eingabe der Nutzerdaten Der Nutzer füllt ein Formular mit seinen persönlichen Informationen aus, um einen neuen Nachweis anzufordern.

Schritt 2: Erstellung des Credential-Angebots Die Anwendung generiert ein sicheres Credential-Angebot, das als QR-Code und Pre-Authorized-Code angezeigt wird.

Schritt 3: Interaktion mit der Wallet Der Nutzer scannt den QR-Code mit einer kompatiblen Wallet (z. B. Sphereon Wallet) und gibt eine PIN ein, um die Ausstellung zu autorisieren.

Schritt 4: Nachweis ausgestellt Die Wallet empfängt und speichert den neu ausgestellten digitalen Nachweis, bereit für die zukünftige Verwendung.

2. Voraussetzungen für die Erstellung eines Issuers#

Bevor wir uns den Code ansehen, gehen wir die Grundlagen und Tools durch, die wir benötigen. Dieser Guide setzt grundlegende Kenntnisse in der Webentwicklung voraus, aber die folgenden Voraussetzungen sind für die Erstellung eines Credential-Issuers unerlässlich.

2.1 Wahl der Protokolle#

Unser Issuer basiert auf einer Reihe offener Standards, die die Interoperabilität zwischen Wallets und Ausstellungsdiensten gewährleisten. Für dieses Tutorial konzentrieren wir uns auf Folgendes:

Standard / ProtokollBeschreibung
OpenID4VCIOpenID for Verifiable Credential Issuance. Dies ist das Kernprotokoll, das wir verwenden werden. Es definiert einen Standardablauf, wie ein Nutzer (über seine Wallet) einen Nachweis von einem Issuer anfordern und erhalten kann.
JWT-VCJWT-basierte Verifiable Credentials. Das Format für den Nachweis, den wir ausstellen werden. Es ist ein W3C-Standard, der Verifiable Credentials als JSON Web Tokens (JWTs) kodiert, was sie kompakt und webfreundlich macht.
ISO mDocISO/IEC 18013-5. Der internationale Standard für mobile Führerscheine (mDLs). Obwohl wir ein JWT-VC ausstellen, sind die Claims darin so strukturiert, dass sie mit dem mDoc-Datenmodell kompatibel sind (z. B. eu.europa.ec.eudi.pid.1).
OAuth 2.0Das zugrunde liegende Autorisierungs-Framework, das von OpenID4VCI verwendet wird. Wir werden einen pre-authorized_code-Flow implementieren, der ein spezifischer Grant-Typ ist, der für eine sichere und benutzerfreundliche Ausstellung von Nachweisen entwickelt wurde.

2.1.1 Autorisierungs-Flows: Pre-Authorized vs. Authorization Code#

OpenID4VCI unterstützt zwei primäre Autorisierungs-Flows für die Ausstellung von Nachweisen:

  1. Pre-Authorized Code Flow: In diesem Flow generiert der Issuer einen kurzlebigen Einmal-Code (pre-authorized_code), der dem Nutzer sofort zur Verfügung steht. Die Wallet des Nutzers kann diesen Code dann direkt gegen einen Nachweis eintauschen. Dieser Flow ist ideal für Szenarien, in denen der Nutzer bereits authentifiziert und auf der Website des Issuers präsent ist, da er eine nahtlose, sofortige Ausstellung ohne Umleitungen ermöglicht.

  2. Authorization Code Flow: Dies ist der Standard-OAuth 2.0-Flow, bei dem der Nutzer zu einem Autorisierungsserver weitergeleitet wird, um seine Zustimmung zu erteilen. Nach der Genehmigung sendet der Server einen authorization_code an eine registrierte redirect_uri zurück. Dieser Flow eignet sich besser für Drittanwendungen, die den Ausstellungsprozess im Namen des Nutzers initiieren.

Für dieses Tutorial verwenden wir den pre-authorized_code-Flow. Wir haben diesen Ansatz gewählt, weil er einfacher ist und eine direktere Benutzererfahrung für unseren spezifischen Anwendungsfall bietet: Ein Nutzer fordert direkt einen Nachweis von der eigenen Website des Issuers an. Er eliminiert die Notwendigkeit komplexer Umleitungen und Client-Registrierungen, was die Kernlogik der Ausstellung einfacher zu verstehen und zu implementieren macht.

Diese Kombination von Standards ermöglicht es uns, einen Issuer zu erstellen, der mit einer Vielzahl von digitalen Wallets kompatibel ist und einen sicheren, standardisierten Prozess für den Nutzer gewährleistet.

2.2 Wahl des Tech-Stacks#

Um unseren Issuer zu erstellen, verwenden wir denselben robusten und modernen Tech-Stack, den wir auch für den Verifier verwendet haben, um eine konsistente und hochwertige Entwicklererfahrung zu gewährleisten.

2.2.1 Sprache: TypeScript#

Wir werden TypeScript sowohl für unseren Frontend- als auch für unseren Backend-Code verwenden. Seine statische Typisierung ist in einer sicherheitskritischen Anwendung wie einem Issuer von unschätzbarem Wert, da sie hilft, häufige Fehler zu vermeiden und die allgemeine Qualität und Wartbarkeit des Codes zu verbessern.

2.2.2 Framework: Next.js#

Next.js ist unser bevorzugtes Framework, da es eine nahtlose, integrierte Erfahrung für die Erstellung von Full-Stack-Anwendungen bietet.

  • Für das Frontend: Wir verwenden Next.js mit React, um die Benutzeroberfläche zu erstellen, auf der Nutzer ihre Daten eingeben können, um einen Nachweis anzufordern.
  • Für das Backend: Wir nutzen Next.js API Routes, um die serverseitigen Endpunkte zu erstellen, die den OpenID4VCI-Flow abwickeln, von der Generierung von Credential-Angeboten bis zur Ausstellung des endgültigen signierten Nachweises.

2.2.3 Wichtige Bibliotheken#

Unsere Implementierung wird auf einige wichtige Bibliotheken zurückgreifen, um spezifische Aufgaben zu erledigen:

  • next, react und react-dom: Die Kernbibliotheken für unsere Next.js-Anwendung.
  • mysql2: Ein MySQL-Client für Node.js, der zur Speicherung von Autorisierungscodes und Sitzungsdaten verwendet wird.
  • uuid: Eine Bibliothek zur Erzeugung eindeutiger Identifikatoren, die wir zur Erstellung von pre-authorized_code-Werten verwenden werden.
  • jose: Eine robuste Bibliothek zur Handhabung von JSON Web Signatures (JWS), die wir verwenden werden, um die von uns ausgestellten Nachweise kryptografisch zu signieren.

2.3 Eine Test-Wallet besorgen#

Um Ihren Issuer zu testen, benötigen Sie eine mobile Wallet, die das OpenID4VCI-Protokoll unterstützt. Für dieses Tutorial empfehlen wir die Sphereon Wallet, die sowohl für Android als auch für iOS verfügbar ist.

So installieren Sie die Sphereon Wallet:

  1. Laden Sie die Wallet aus dem Google Play Store oder dem Apple App Store herunter.
  2. Installieren Sie die App auf Ihrem Mobilgerät.
  3. Nach der Installation ist die Wallet bereit, Credential-Angebote durch Scannen eines QR-Codes zu empfangen.

2.4 Kryptografie-Kenntnisse#

Die Ausstellung eines Nachweises ist ein sicherheitskritischer Vorgang, der auf grundlegenden kryptografischen Konzepten beruht, um Vertrauen und Authentizität zu gewährleisten.

2.4.1 Digitale Signaturen#

Im Kern ist ein Verifiable Credential eine Reihe von Behauptungen (Claims), die vom Issuer digital signiert wurden. Diese Signatur bietet zwei Garantien:

  • Authentizität: Sie beweist, dass der Nachweis von einem legitimen Issuer erstellt wurde.
  • Integrität: Sie beweist, dass der Nachweis seit seiner Ausstellung nicht manipuliert wurde.

2.4.2 Public-Key-Kryptografie#

Digitale Signaturen werden mittels Public-Key-Kryptografie erstellt. So funktioniert es:

  1. Der Issuer besitzt ein Schlüsselpaar: einen privaten Schlüssel, der geheim und sicher aufbewahrt wird, und einen entsprechenden öffentlichen Schlüssel, der öffentlich zugänglich gemacht wird.
  2. Signieren: Wenn der Issuer einen Nachweis erstellt, verwendet er seinen privaten Schlüssel, um eine eindeutige digitale Signatur für die Nachweisdaten zu erzeugen.
  3. Verifizierung: Ein Verifier kann später den öffentlichen Schlüssel des Issuers verwenden, um die Signatur zu überprüfen. Wenn die Prüfung erfolgreich ist, weiß der Verifier, dass der Nachweis authentisch ist und nicht verändert wurde.

In unserer Implementierung generieren wir ein Elliptic Curve (EC)-Schlüsselpaar und verwenden den ES256-Algorithmus, um das JWT-VC zu signieren. Der öffentliche Schlüssel ist in der DID (did:web) des Issuers eingebettet, sodass jeder Verifier ihn entdecken und die Signatur des Nachweises validieren kann. Hinweis: Der aud (Audience)-Claim wird in unseren JWTs absichtlich weggelassen, da der Nachweis für allgemeine Zwecke konzipiert ist und nicht an eine bestimmte Wallet gebunden sein soll. Wenn Sie die Nutzung auf eine bestimmte Zielgruppe beschränken möchten, fügen Sie einen aud-Claim hinzu und setzen Sie ihn entsprechend.

3. Architekturübersicht#

Unsere Issuer-Anwendung ist als Full-Stack-Next.js-Projekt aufgebaut, mit einer klaren Trennung zwischen Frontend- und Backend-Logik. Diese Architektur ermöglicht es uns, eine nahtlose Benutzererfahrung zu schaffen, während alle sicherheitskritischen Operationen auf dem Server abgewickelt werden. Wichtig: Die enthaltenen Tabellen verification_sessions und verified_credentials im SQL sind für diesen Issuer nicht erforderlich, werden aber der Vollständigkeit halber aufgeführt.

  • Frontend (src/app/issue/page.tsx): Eine einzelne React-Seite, auf der Nutzer ihre Daten eingeben können, um einen Nachweis anzufordern. Sie macht API-Aufrufe an unser Backend, um den Ausstellungsprozess zu initiieren.
  • Backend API Routes (src/app/api/issue/...): Eine Reihe von serverseitigen Endpunkten, die das OpenID4VCI-Protokoll implementieren.
    • /.well-known/openid-credential-issuer: Ein öffentlicher Metadaten-Endpunkt. Dies ist die erste URL, die eine Wallet überprüft, um die Fähigkeiten des Issuers zu entdecken, einschließlich seines Autorisierungsservers, Token-Endpunkts, Credential-Endpunkts und der Arten von Nachweisen, die er anbietet.
    • /.well-known/openid-configuration: Ein Standard-OpenID-Connect-Discovery-Endpunkt. Obwohl eng mit dem obigen verwandt, dient dieser Endpunkt einer breiteren OIDC-bezogenen Konfiguration und ist oft für die Interoperabilität mit Standard-OpenID-Clients erforderlich.
    • /.well-known/did.json: Das DID-Dokument für unseren Issuer. Bei Verwendung der did:web-Methode wird diese Datei verwendet, um die öffentlichen Schlüssel des Issuers zu veröffentlichen, die Verifier zur Validierung der Signaturen der von ihm ausgestellten Nachweise verwenden können.
    • authorize/route.ts: Erstellt einen pre-authorized_code und ein Credential-Angebot.
    • token/route.ts: Tauscht den pre-authorized_code gegen einen Access Token ein.
    • credential/route.ts: Stellt das endgültige, kryptografisch signierte JWT-VC aus.
    • schemas/pid/route.ts: Stellt das JSON-Schema für den PID-Nachweis bereit. Dies ermöglicht es jedem Konsumenten des Nachweises, dessen Struktur und Datentypen zu verstehen.
  • Bibliothek (src/lib/):
    • database.ts: Verwaltet alle Datenbankinteraktionen, wie das Speichern von Autorisierungscodes und Issuer-Schlüsseln.
    • crypto.ts: Handhabt alle kryptografischen Operationen, einschließlich der Schlüsselgenerierung und JWT-Signierung.

Hier ist ein Diagramm, das den Ausstellungsprozess veranschaulicht:

4. Den Issuer erstellen#

Nachdem wir nun ein solides Verständnis der Standards, Protokolle und Architektur haben, können wir mit der Erstellung unseres Issuers beginnen.

Mitmachen oder den fertigen Code verwenden

Wir werden nun Schritt für Schritt durch die Einrichtung und Code-Implementierung gehen. Wenn Sie lieber direkt zum fertigen Produkt springen möchten, können Sie das vollständige Projekt aus unserem GitHub-Repository klonen und lokal ausführen.

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

4.1 Projekt einrichten#

Zuerst initialisieren wir ein neues Next.js-Projekt, installieren die notwendigen Abhängigkeiten und starten unsere Datenbank.

4.1.1 Initialisieren der Next.js-App#

Öffnen Sie Ihr Terminal, navigieren Sie zu dem Verzeichnis, in dem Sie Ihr Projekt erstellen möchten, und führen Sie den folgenden Befehl aus. Wir verwenden für dieses Projekt den App Router, TypeScript und Tailwind CSS.

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

Dieser Befehl erstellt eine neue Next.js-Anwendung in Ihrem aktuellen Verzeichnis.

4.1.2 Abhängigkeiten installieren#

Als Nächstes müssen wir die Bibliotheken installieren, die sich um JWTs, Datenbankverbindungen und die Generierung von UUIDs kümmern.

npm install jose mysql2 uuid @types/uuid

Dieser Befehl installiert:

  • jose: Zum Signieren und Verifizieren von JSON Web Tokens (JWTs).
  • mysql2: Den MySQL-Client für unsere Datenbank.
  • uuid: Zur Generierung eindeutiger Challenge-Strings.
  • @types/uuid: TypeScript-Typen für die uuid-Bibliothek.

4.1.3 Datenbank starten#

Unser Backend benötigt eine MySQL-Datenbank, um Autorisierungscodes, Ausstellungssitzungen und Issuer-Schlüssel zu speichern. Wir haben eine docker-compose.yml-Datei beigefügt, um dies zu vereinfachen.

Wenn Sie das Repository geklont haben, können Sie einfach docker-compose up -d ausführen. Wenn Sie von Grund auf neu bauen, erstellen Sie eine Datei namens docker-compose.yml mit dem folgenden Inhalt:

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:

Dieses Docker-Compose-Setup erfordert auch ein SQL-Initialisierungsskript. Erstellen Sie ein Verzeichnis namens sql und darin eine Datei namens init.sql mit dem folgenden Inhalt, um die notwendigen Tabellen sowohl für den Verifier als auch für den Issuer einzurichten:

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

Sobald beide Dateien vorhanden sind, öffnen Sie Ihr Terminal im Projektstammverzeichnis und führen Sie aus:

docker-compose up -d

Dieser Befehl startet einen MySQL-Container im Hintergrund, der für unsere Anwendung bereit ist.

4.2 Implementierung der Shared Libraries#

Bevor wir die API-Endpunkte erstellen, lassen Sie uns die Shared Libraries erstellen, die die Kern-Geschäftslogik behandeln. Dieser Ansatz hält unsere API-Routen sauber und auf die Verarbeitung von HTTP-Anfragen konzentriert, während die komplexe Arbeit an diese Module delegiert wird.

4.2.1 Die Datenbank-Bibliothek (src/lib/database.ts)#

Diese Datei ist die alleinige Quelle der Wahrheit für alle Datenbankinteraktionen. Sie verwendet die mysql2-Bibliothek, um sich mit unserem MySQL-Container zu verbinden, und stellt eine Reihe von exportierten Funktionen zum Erstellen, Lesen und Aktualisieren von Datensätzen in unseren Tabellen bereit. Diese Abstraktionsschicht macht unseren Code modularer und einfacher zu warten.

Erstellen Sie die Datei src/lib/database.ts mit dem folgenden Inhalt:

// src/lib/database.ts import mysql from "mysql2/promise"; // Konfiguration der Datenbankverbindung 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)-Funktionen für jede Tabelle // ... (z. B. createChallenge, getChallenge, createAuthorizationCode, etc.)

Hinweis: Aus Gründen der Kürze wurde die vollständige Liste der DAO-Funktionen weggelassen. Den vollständigen Code finden Sie im Projekt-Repository. Diese Datei enthält Funktionen zur Verwaltung von Challenges, Verifizierungssitzungen, Autorisierungscodes, Ausstellungssitzungen und Issuer-Schlüsseln.

4.2.2 Die Krypto-Bibliothek (src/lib/crypto.ts)#

Diese Datei behandelt alle sicherheitskritischen kryptografischen Operationen. Sie verwendet die jose-Bibliothek, um Schlüsselpaare zu generieren und JSON Web Tokens (JWTs) zu signieren.

Schlüsselgenerierung Die Funktion generateIssuerKeyPair erstellt ein neues Elliptic-Curve-Schlüsselpaar, das zum Signieren von Nachweisen verwendet wird. Der öffentliche Schlüssel wird im JSON Web Key (JWK)-Format exportiert, sodass er in unserem did.json-Dokument veröffentlicht werden kann.

// 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; // Eindeutige Schlüssel-ID zuweisen // ... (Export des privaten Schlüssels und weitere Einrichtung) return { publicKey, privateKey, publicKeyJWK /* ... */ }; }

Erstellung von JWT-Credentials Die Funktion createJWTVerifiableCredential ist der Kern des Ausstellungsprozesses. Sie nimmt die Claims des Nutzers, das Schlüsselpaar des Issuers und andere Metadaten und erstellt daraus ein signiertes 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 = { // Die DID des Issuers iss: issuerKeyPair.issuerDid, // Die DID des Subjekts (Holders) sub: subjectId, // Zeitpunkt der Ausstellung (iat) und des Ablaufs (exp) des Nachweises iat: now, exp: now + oneYear, // Das Datenmodell für Verifiable Credentials 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, }, }, }; // Signieren des Payloads mit dem privaten Schlüssel des Issuers return await new SignJWT(vcPayload) .setProtectedHeader({ alg: issuerKeyPair.algorithm, kid: issuerKeyPair.keyId, typ: "JWT", }) .sign(issuerKeyPair.privateKey); }

Diese Funktion erstellt den JWT-Payload gemäß dem W3C Verifiable Credentials Data Model und signiert ihn mit dem privaten Schlüssel des Issuers, wodurch ein sicherer und verifizierbarer Nachweis entsteht.

4.2 Architekturübersicht der Next.js-App#

Unsere Next.js-Anwendung ist so strukturiert, dass die Belange zwischen Frontend und Backend getrennt sind, obwohl sie Teil desselben Projekts sind. Dies wird erreicht, indem der App Router sowohl für UI-Seiten als auch für API-Endpunkte genutzt wird.

  • Frontend (src/app/issue/page.tsx): Eine einzelne React-Seitenkomponente, die die Benutzeroberfläche für die /issue-Route definiert. Sie verarbeitet Benutzereingaben und kommuniziert mit unserer Backend-API.

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

    • Discovery (.well-known/.../route.ts): Diese Routen stellen öffentliche Metadaten-Endpunkte bereit, die es Wallets und anderen Clients ermöglichen, die Fähigkeiten und öffentlichen Schlüssel des Issuers zu entdecken.
    • Issuance (issue/.../route.ts): Diese Endpunkte implementieren die Kernlogik von OpenID4VCI, einschließlich der Erstellung von Credential-Angeboten, der Ausstellung von Tokens und der Signierung des endgültigen Nachweises.
    • Schema (schemas/pid/route.ts): Diese Route liefert das JSON-Schema für den Nachweis und definiert dessen Struktur.
  • Library (src/lib/): Dieses Verzeichnis enthält wiederverwendbare Logik, die im gesamten Backend geteilt wird.

    • database.ts: Verwaltet alle Datenbankinteraktionen und abstrahiert SQL-Abfragen.
    • crypto.ts: Handhabt alle kryptografischen Operationen, wie z. B. die Schlüsselgenerierung und JWT-Signierung.

Diese klare Trennung macht die Anwendung modular und einfacher zu warten.

Hinweis: Die Funktion generateIssuerDid() muss eine gültige did:web zurückgeben, die Ihrer Issuer-Domäne entspricht. Bei der Bereitstellung muss die Datei .well-known/did.json über HTTPS auf dieser Domäne bereitgestellt werden, damit Verifier die Nachweise validieren können.

4.3 Erstellen des Frontends#

Unser Frontend ist eine einzelne React-Seite, die ein einfaches Formular für Benutzer bereitstellt, um einen neuen digitalen Nachweis anzufordern. Seine Aufgaben sind:

  • Benutzerdaten erfassen (Name, Geburtsdatum usw.).
  • Diese Daten an unser Backend senden, um ein Credential-Angebot zu erstellen.
  • Den resultierenden QR-Code und die PIN anzeigen, damit der Benutzer sie mit seiner Wallet scannen kann.

Die Kernlogik wird in der handleSubmit-Funktion behandelt, die ausgelöst wird, wenn der Benutzer das Formular abschickt.

// src/app/issue/page.tsx const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setLoading(true); setError(null); setCredentialOffer(null); try { // 1. Erforderliche Felder validieren if (!userData.given_name || !userData.family_name || !userData.birth_date) { throw new Error("Bitte füllen Sie alle erforderlichen Felder aus"); } // 2. Ein Credential-Angebot vom Backend anfordern 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 || "Fehler beim Erstellen des Credential-Angebots", ); } // 3. Das Credential-Angebot im State setzen, um den QR-Code anzuzeigen const result = await response.json(); setCredentialOffer(result); } catch (err) { const errorMessage = (err as Error).message || "Unbekannter Fehler aufgetreten"; setError(errorMessage); } finally { setLoading(false); } };

Diese Funktion führt drei wesentliche Aktionen aus:

  1. Validiert die Formulardaten, um sicherzustellen, dass alle erforderlichen Felder ausgefüllt sind.
  2. Sendet eine POST-Anfrage an unseren /api/issue/authorize-Endpunkt mit den Daten des Benutzers.
  3. Aktualisiert den Zustand der Komponente mit dem vom Backend erhaltenen Credential-Angebot, was die Benutzeroberfläche veranlasst, den QR-Code und den Transaktionscode anzuzeigen.

Der Rest der Datei enthält Standard-React-Code zum Rendern des Formulars und der QR-Code-Anzeige. Sie können die vollständige Datei im Projekt-Repository einsehen.

4.4 Einrichtung der Umgebung und Discovery#

Bevor wir die Backend-API erstellen, müssen wir unsere Umgebung konfigurieren und die Discovery-Endpunkte einrichten. Diese .well-known-Dateien sind entscheidend, damit Wallets unseren Issuer finden und verstehen können, wie sie mit ihm interagieren können.

4.4.1 Erstellen der Umgebungsdatei#

Erstellen Sie eine Datei namens .env.local im Stammverzeichnis Ihres Projekts und fügen Sie die folgende Zeile hinzu. Diese URL muss öffentlich zugänglich sein, damit eine mobile Wallet sie erreichen kann. Für die lokale Entwicklung können Sie einen Tunneling-Dienst wie ngrok verwenden, um Ihren localhost freizugeben.

NEXT_PUBLIC_BASE_URL=http://localhost:3000

4.4.2 Implementierung der Discovery-Endpunkte#

Wallets entdecken die Fähigkeiten eines Issuers, indem sie Standard-.well-known-URLs abfragen. Wir müssen drei dieser Endpunkte erstellen.

1. Issuer-Metadaten (/.well-known/openid-credential-issuer)

Dies ist die primäre Discovery-Datei für OpenID4VCI. Sie teilt der Wallet alles mit, was sie über den Issuer wissen muss, einschließlich seiner Endpunkte, der Arten von Nachweisen, die er anbietet, und der unterstützten kryptografischen Algorithmen.

Erstellen Sie die Datei 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 = { // Der eindeutige Bezeichner des Issuers. issuer: baseUrl, // Die URL des Autorisierungsservers. Der Einfachheit halber ist unser Issuer sein eigener Autorisierungsserver. authorization_servers: [baseUrl], // Die URL des Credential-Issuers. credential_issuer: baseUrl, // Der Endpunkt, an den die Wallet POST-Anfragen sendet, um den eigentlichen Nachweis zu erhalten. credential_endpoint: `${baseUrl}/api/issue/credential`, // Der Endpunkt, an dem die Wallet einen Autorisierungscode gegen einen Access Token austauscht. token_endpoint: `${baseUrl}/api/issue/token`, // Der Endpunkt für den Autorisierungs-Flow (wird in unserem Pre-Authorized-Flow nicht verwendet, aber es ist gute Praxis, ihn anzugeben). authorization_endpoint: `${baseUrl}/api/issue/authorize`, // Zeigt die Unterstützung für den Pre-Authorized-Code-Flow ohne Client-Authentifizierung an. pre_authorized_grant_anonymous_access_supported: true, // Lesbare Informationen über den Issuer. display: [ { name: "Corbado Credentials Issuer", locale: "en-US", }, ], // Eine Liste der Nachweistypen, die dieser Issuer ausstellen kann. credential_configurations_supported: { "eu.europa.ec.eudi.pid.1": { // Das Format des Nachweises (z. B. jwt_vc, mso_mdoc). format: "jwt_vc", // Der spezifische Dokumententyp, der den ISO-mDoc-Standards entspricht. doctype: "eu.europa.ec.eudi.pid.1", // Der mit diesem Nachweistyp verbundene OAuth 2.0-Scope. scope: "eu.europa.ec.eudi.pid.1", // Methoden, die die Wallet verwenden kann, um den Besitz ihres Schlüssels nachzuweisen. cryptographic_binding_methods_supported: ["jwk"], // Signaturalgorithmen, die der Issuer für diesen Nachweis unterstützt. credential_signing_alg_values_supported: ["ES256"], // Proof-of-Possession-Typen, die die Wallet verwenden kann. proof_types_supported: { jwt: { proof_signing_alg_values_supported: ["ES256", "ES384", "ES512"], }, }, // Anzeige-Eigenschaften für den Nachweis. display: [ { name: "Corbado Credential Issuer", locale: "en-US", logo: { uri: `${baseUrl}/logo.png`, alt_text: "EU Digital Identity", }, background_color: "#003399", text_color: "#FFFFFF", }, ], // Eine Liste der Claims (Attribute) im Nachweis. 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" }], }, }, }, }, }, // Authentifizierungsmethoden, die vom Token-Endpunkt unterstützt werden. 'none' bedeutet öffentlicher Client. token_endpoint_auth_methods_supported: ["none"], // Unterstützte PKCE-Code-Challenge-Methoden. code_challenge_methods_supported: ["S256"], // OAuth 2.0 Grant-Typen, die der Issuer unterstützt. 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-Konfiguration (/.well-known/openid-configuration)

Dies ist ein Standard-OIDC-Discovery-Dokument, das einen breiteren Satz von Konfigurationsdetails bereitstellt.

Erstellen Sie die Datei 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 = { // Der eindeutige Bezeichner des Issuers. credential_issuer: baseUrl, // Der Endpunkt, an den die Wallet POST-Anfragen sendet, um den eigentlichen Nachweis zu erhalten. credential_endpoint: `${baseUrl}/api/issue/credential`, // Der Endpunkt für den Autorisierungs-Flow. authorization_endpoint: `${baseUrl}/api/issue/authorize`, // Der Endpunkt, an dem die Wallet einen Autorisierungscode gegen einen Access Token austauscht. token_endpoint: `${baseUrl}/api/issue/token`, // Eine Liste der Nachweistypen, die dieser Issuer ausstellen kann. 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-Typen, die der Issuer unterstützt. grant_types_supported: [ "authorization_code", "urn:ietf:params:oauth:grant-type:pre-authorized_code", ], // Zeigt die Unterstützung für den Pre-Authorized-Code-Flow an. pre_authorized_grant_anonymous_access_supported: true, // Unterstützte PKCE-Code-Challenge-Methoden. code_challenge_methods_supported: ["S256"], // Authentifizierungsmethoden, die vom Token-Endpunkt unterstützt werden. token_endpoint_auth_methods_supported: ["none"], // OAuth 2.0-Scopes, die der Issuer unterstützt. 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-Dokument (/.well-known/did.json)

Diese Datei veröffentlicht den öffentlichen Schlüssel des Issuers unter Verwendung der did:web-Methode, sodass jeder die Signatur der von ihm ausgestellten Nachweise überprüfen kann.

Erstellen Sie die Datei 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 = { // Der Kontext definiert das im Dokument verwendete Vokabular. "@context": [ "https://www.w3.org/ns/did/v1", "https://w3id.org/security/suites/jws-2020/v1", ], // Der DID-URI, der der eindeutige Bezeichner für den Issuer ist. id: didId, // Der DID-Controller, also die Entität, die die DID kontrolliert. Hier ist es der Issuer selbst. controller: didId, // Eine Liste von öffentlichen Schlüsseln, die zur Überprüfung von Signaturen des Issuers verwendet werden können. verificationMethod: [ { // Ein eindeutiger Bezeichner für den Schlüssel, bezogen auf die DID. id: `${didId}#${issuerKey.key_id}`, // Der Typ des Schlüssels. type: "JsonWebKey2020", // Die DID des Schlüssel-Controllers. controller: didId, // Der öffentliche Schlüssel im JWK-Format. publicKeyJwk: publicKeyJWK, }, ], // Gibt an, welche Schlüssel zur Authentifizierung (Nachweis der Kontrolle über die DID) verwendet werden können. authentication: [`${didId}#${issuerKey.key_id}`], // Gibt an, welche Schlüssel zur Erstellung von Verifiable Credentials verwendet werden können. assertionMethod: [`${didId}#${issuerKey.key_id}`], // Eine Liste von Diensten, die vom DID-Subjekt bereitgestellt werden, wie z. B. der Issuer-Endpunkt. 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", }, }); }

Warum kein Caching? Sie werden feststellen, dass alle drei dieser Endpunkte Header zurückgeben, die das Caching aggressiv verhindern (Cache-Control: no-cache, Pragma: no-cache, Expires: 0). Dies ist eine wichtige Sicherheitspraxis für Discovery-Dokumente. Issuer-Konfigurationen können sich ändern – zum Beispiel könnte ein kryptografischer Schlüssel rotiert werden. Wenn eine Wallet oder ein Client eine alte Version der did.json- oder openid-credential-issuer-Datei zwischenspeichern würde, könnte sie neue Nachweise nicht validieren oder mit aktualisierten Endpunkten interagieren. Indem wir Clients zwingen, bei jeder Anfrage eine frische Kopie abzurufen, stellen wir sicher, dass sie immer die aktuellsten Informationen haben.

4.4.3 Implementierung des Credential-Schema-Endpunkts#

Das letzte Stück unserer öffentlich zugänglichen Infrastruktur ist der Credential-Schema-Endpunkt. Diese Route liefert ein JSON-Schema, das die Struktur, Datentypen und Einschränkungen des von uns ausgestellten PID-Nachweises formal definiert. Wallets und Verifier können dieses Schema verwenden, um den Inhalt des Nachweises zu validieren.

Erstellen Sie die Datei src/app/api/schemas/pid/route.ts mit dem folgenden Inhalt:

// 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", // Ersetzen Sie dies durch Ihre tatsächliche 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" }, // ... andere Eigenschaften des Credential-Subjekts }, required: ["given_name", "family_name", "birth_date"], }, // ... andere Top-Level-Eigenschaften eines Verifiable Credentials }, }; return NextResponse.json(schema, { headers: { "Content-Type": "application/schema+json", "Access-Control-Allow-Origin": "*", // Cross-Origin-Anfragen erlauben }, }); }

Hinweis: Das JSON-Schema für einen PID-Nachweis kann ziemlich groß und detailliert sein. Aus Gründen der Kürze wurde das vollständige Schema gekürzt. Sie finden die vollständige Datei im Projekt-Repository.

4.5 Erstellen der Backend-Endpunkte#

Mit dem Frontend an Ort und Stelle benötigen wir nun die serverseitige Logik, um den OpenID4VCI-Flow zu handhaben. Wir beginnen mit dem ersten Endpunkt, den das Frontend aufruft: /api/issue/authorize.

4.5.1 /api/issue/authorize: Erstellen des Credential-Angebots#

Dieser Endpunkt ist dafür verantwortlich, die Daten des Benutzers entgegenzunehmen, einen sicheren Einmal-Code zu generieren und ein credential_offer zu erstellen, das die Wallet des Benutzers verstehen kann.

Hier ist die Kernlogik:

// 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. Benutzerdaten validieren 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. Einen Pre-Authorized-Code und eine PIN generieren const code = uuidv4(); const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 Minuten const txCode = Math.floor(1000 + Math.random() * 9000).toString(); // 4-stellige PIN // 3. Den Code und die Benutzerdaten speichern await createAuthorizationCode(uuidv4(), code, expiresAt); // Hinweis: Dies verwendet nur zu Demozwecken einen In-Memory-Speicher. // In der Produktion sollten Daten sicher in einer Datenbank mit korrektem Ablaufdatum gespeichert werden. 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. Das Credential-Angebot-Objekt erstellen const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3000"; const credentialOffer = { // Der Bezeichner des Issuers, also seine Basis-URL. credential_issuer: baseUrl, // Ein Array von Nachweistypen, die der Issuer anbietet. credential_configuration_ids: ["eu.europa.ec.eudi.pid.1"], // Gibt die Grant-Typen an, die die Wallet verwenden kann. grants: { // Wir verwenden den Pre-Authorized-Code-Flow. "urn:ietf:params:oauth:grant-type:pre-authorized_code": { // Der Einmal-Code, den die Wallet gegen einen Token eintauschen wird. "pre-authorized_code": code, // Zeigt an, dass der Benutzer eine PIN (tx_code) eingeben muss, um den Code einzulösen. user_pin_required: true, }, }, }; // 5. Die vollständige Credential-Offer-URI erstellen (ein Deep-Link für Wallets) const credentialOfferUri = `openid-credential-offer://?credential_offer=${encodeURIComponent( JSON.stringify(credentialOffer), )}`; // Die endgültige Antwort an das Frontend. return NextResponse.json({ // Der Deep-Link für den QR-Code. credential_offer_uri: credentialOfferUri, // Der rohe Pre-Authorized-Code zur Anzeige oder manuellen Eingabe. pre_authorized_code: code, // Die 4-stellige PIN, die der Benutzer in seiner Wallet eingeben muss. tx_code: txCode, }); } catch (error) { console.error("Authorization error:", error); return NextResponse.json({ error: "server_error" }, { status: 500 }); } }

Wichtige Schritte in diesem Endpunkt:

  1. Daten validieren: Zuerst wird sichergestellt, dass die erforderlichen Benutzerdaten vorhanden sind.
  2. Codes generieren: Es wird ein eindeutiger pre-authorized_code (eine UUID) und ein 4-stelliger tx_code (PIN) für eine zusätzliche Sicherheitsebene erstellt.
  3. Daten speichern: Der pre-authorized_code wird mit einer kurzen Ablaufzeit in der Datenbank gespeichert. Die Daten des Benutzers und die PIN werden im Speicher abgelegt und mit dem Code verknüpft.
  4. Angebot erstellen: Es wird das credential_offer-Objekt gemäß der OpenID4VCI-Spezifikation erstellt. Dieses Objekt teilt der Wallet mit, wo sich der Issuer befindet, welche Nachweise er anbietet und welcher Code benötigt wird, um sie zu erhalten.
  5. URI zurückgeben: Schließlich wird eine Deep-Link-URI (openid-credential-offer://...) erstellt und zusammen mit dem tx_code an das Frontend zurückgegeben, damit der Benutzer ihn sehen kann.

4.5.2 /api/issue/token: Den Code gegen einen Token austauschen#

Sobald der Benutzer den QR-Code scannt und seine PIN eingibt, sendet die Wallet eine POST-Anfrage an diesen Endpunkt. Seine Aufgabe ist es, den pre-authorized_code und den user_pin (PIN) zu validieren und, falls sie gültig sind, einen kurzlebigen Access Token auszustellen.

Erstellen Sie die Datei src/app/api/issue/token/route.ts mit dem folgenden Inhalt:

// 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. Den Grant-Typ validieren if (grant_type !== "urn:ietf:params:oauth:grant-type:pre-authorized_code") { return NextResponse.json( { error: "unsupported_grant_type" }, { status: 400 }, ); } // 2. Den Pre-Authorized-Code validieren const authCode = await getAuthorizationCode(code); if (!authCode) { return NextResponse.json( { error: "invalid_grant", error_description: "Invalid or expired code", }, { status: 400 }, ); } // 3. Die PIN (tx_code) validieren 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. Access Token und c_nonce generieren const accessToken = uuidv4(); const cNonce = uuidv4(); const cNonceExpiresAt = new Date(Date.now() + 5 * 60 * 1000); // 5 Minuten // 5. Eine neue Ausstellungssitzung erstellen const userData = (global as any).userDataStore?.get(code); await createIssuanceSession( uuidv4(), authCode.id, accessToken, cNonce, cNonceExpiresAt, userData, ); // 6. Den Code als verwendet markieren und temporäre Daten bereinigen await markAuthorizationCodeAsUsed(code); (global as any).txCodeStore?.delete(code); (global as any).userDataStore?.delete(code); // 7. Die Access-Token-Antwort zurückgeben return NextResponse.json({ access_token: accessToken, token_type: "Bearer", expires_in: 3600, // 1 Stunde c_nonce: cNonce, c_nonce_expires_in: 300, // 5 Minuten }); } catch (error) { console.error("Token endpoint error:", error); return NextResponse.json({ error: "server_error" }, { status: 500 }); } }

Wichtige Schritte in diesem Endpunkt:

  1. Grant-Typ validieren: Es wird sichergestellt, dass die Wallet den korrekten pre-authorized_code-Grant-Typ verwendet.
  2. Code validieren: Es wird geprüft, ob der pre-authorized_code in der Datenbank existiert, nicht abgelaufen ist und noch nicht verwendet wurde.
  3. PIN validieren: Es wird der user_pin aus der Wallet mit dem zuvor gespeicherten tx_code verglichen, um sicherzustellen, dass der Benutzer die Transaktion autorisiert hat.
  4. Tokens generieren: Es werden ein sicherer access_token und eine c_nonce (Credential Nonce) erstellt, die ein einmaliger Wert ist, um Replay-Angriffe auf den Credential-Endpunkt zu verhindern.
  5. Sitzung erstellen: Es wird ein neuer issuance_sessions-Eintrag in der Datenbank erstellt, der den Access Token mit den Daten des Benutzers verknüpft.
  6. Code als verwendet markieren: Um zu verhindern, dass dasselbe Angebot zweimal verwendet wird, wird der pre-authorized_code als verwendet markiert.
  7. Token zurückgeben: Es werden der access_token und die c_nonce an die Wallet zurückgegeben.

4.5.3 /api/issue/credential: Den signierten Nachweis ausstellen#

Dies ist der letzte und wichtigste Endpunkt. Die Wallet verwendet den Access Token, den sie vom /token-Endpunkt erhalten hat, um eine authentifizierte POST-Anfrage an diese Route zu senden. Die Aufgabe dieses Endpunkts ist es, die endgültige Validierung durchzuführen, den kryptografisch signierten Nachweis zu erstellen und ihn an die Wallet zurückzugeben.

Erstellen Sie die Datei src/app/api/issue/credential/route.ts mit dem folgenden Inhalt:

// 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. Den Bearer-Token validieren 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. Die Benutzerdaten aus der Sitzung abrufen const userData = session.user_data; if (!userData) { return NextResponse.json({ error: "missing_user_data" }, { status: 400 }); } // 3. Den aktiven Issuer-Schlüssel abrufen const issuerKey = await getActiveIssuerKey(); if (!issuerKey) { // In einer echten Anwendung hätten Sie ein robusteres Schlüsselverwaltungssystem. // Für diese Demo können wir einen Schlüssel on-the-fly generieren, wenn keiner existiert. // Dieser Teil wurde aus Gründen der Kürze weggelassen, befindet sich aber im Repository. return NextResponse.json( { error: "server_error", error_description: "Failed to get issuer key", }, { status: 500 }, ); } // 4. Den JWT-VC erstellen 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. Den ausgestellten Nachweis in der Datenbank speichern await createIssuedCredential(/* ... Nachweisdetails ... */); await updateIssuanceSession(session.id, "credential_issued"); // 6. Den signierten Nachweis zurückgeben return NextResponse.json({ format: "jwt_vc", credential: credentialData, c_nonce: uuidv4(), // Eine neue Nonce für nachfolgende Anfragen c_nonce_expires_in: 300, }); } catch (error) { console.error("Credential endpoint error:", error); return NextResponse.json({ error: "server_error" }, { status: 500 }); } }

Wichtige Schritte in diesem Endpunkt:

  1. Token validieren: Es wird nach einem gültigen Bearer-Token im Authorization-Header gesucht und damit die aktive Ausstellungssitzung nachgeschlagen.
  2. Benutzerdaten abrufen: Es werden die Claims-Daten des Benutzers abgerufen, die in der Sitzung gespeichert wurden, als der Token erstellt wurde.
  3. Issuer-Schlüssel laden: Es wird der aktive Signierschlüssel des Issuers aus der Datenbank geladen. In einem realen Szenario würde dies von einem sicheren Schlüsselverwaltungssystem übernommen.
  4. Nachweis erstellen: Es wird unser Helfer createJWTVerifiableCredential aus src/lib/crypto.ts aufgerufen, um den JWT-VC zu erstellen und zu signieren.
  5. Ausstellung protokollieren: Es wird ein Eintrag des ausgestellten Nachweises in der Datenbank für Audit- und Widerrufszwecke gespeichert.
  6. Nachweis zurückgeben: Es wird der signierte Nachweis in einer JSON-Antwort an die Wallet zurückgegeben. Die Wallet ist dann für die sichere Speicherung verantwortlich.

5. Den Issuer ausführen und nächste Schritte#

Sie haben jetzt eine vollständige End-to-End-Implementierung eines Issuers für digitale Nachweise. Hier erfahren Sie, wie Sie ihn lokal ausführen und was Sie beachten müssen, um ihn von einem Proof-of-Concept zu einer produktionsreifen Anwendung zu machen.

5.1 Wie man das Beispiel ausführt#

  1. Das Repository klonen:

    git clone https://github.com/corbado/digital-credentials-example.git cd digital-credentials-example
  2. Abhängigkeiten installieren:

    npm install
  3. Die Datenbank starten: Stellen Sie sicher, dass Docker läuft, und starten Sie dann den MySQL-Container:

    docker-compose up -d
  4. Umgebung konfigurieren & Tunnel starten: Dies ist der kritischste Schritt für lokale Tests. Da Ihre mobile Wallet sich über das Internet mit Ihrer Entwicklungsmaschine verbinden muss, müssen Sie Ihren lokalen Server mit einer öffentlichen HTTPS-URL freigeben. Wir verwenden dafür ngrok.

    a. ngrok starten:

    ngrok http 3000

    b. Kopieren Sie die HTTPS-URL aus der ngrok-Ausgabe (z. B. https://random-string.ngrok.io). c. Erstellen Sie eine .env.local-Datei und setzen Sie die URL:

    NEXT_PUBLIC_BASE_URL=https://<your-ngrok-url>
  5. Die Anwendung ausführen:

    npm run dev

    Öffnen Sie Ihren Browser unter http://localhost:3000/issue. Sie können nun das Formular ausfüllen, und der generierte QR-Code wird korrekt auf Ihre öffentliche ngrok-URL verweisen, sodass Ihre mobile Wallet sich verbinden und den Nachweis empfangen kann.

5.2 Die Bedeutung von HTTPS und ngrok#

Protokolle für digitale Nachweise werden mit Sicherheit als oberster Priorität entwickelt. Aus diesem Grund werden Wallets fast immer die Verbindung zu einem Issuer über eine unsichere (http://) Verbindung ablehnen. Der gesamte Prozess basiert auf einer sicheren HTTPS-Verbindung, die durch ein SSL-Zertifikat ermöglicht wird.

Ein Tunneldienst wie ngrok löst beide Probleme, indem er eine sichere, öffentlich zugängliche HTTPS-URL (mit einem gültigen SSL-Zertifikat) erstellt, die den gesamten Datenverkehr an Ihren lokalen Entwicklungsserver weiterleitet. Wallets erfordern HTTPS und werden sich weigern, sich mit unsicheren (http://) Endpunkten zu verbinden. Dies ist ein unverzichtbares Werkzeug zum Testen von Webdiensten, die mit mobilen Geräten oder externen Webhooks interagieren müssen.

5.3 Was nicht im Rahmen dieses Tutorials liegt#

Dieses Beispiel konzentriert sich bewusst auf den Kern des Ausstellungsprozesses, um es leicht verständlich zu machen. Die folgenden Themen werden als außerhalb des Rahmens betrachtet:

  • Produktionsreife Sicherheit: Der Issuer dient zu Bildungszwecken. Ein Produktionssystem würde ein sicheres Schlüsselverwaltungssystem (KMS) anstelle der Speicherung von Schlüsseln in einer Datenbank, eine robuste Fehlerbehandlung, Ratenbegrenzung und eine umfassende Audit-Protokollierung erfordern.
  • Widerruf von Nachweisen: Dieser Leitfaden implementiert keinen Mechanismus zum Widerrufen von ausgestellten Nachweisen. Obwohl das Schema ein revoked-Flag für die zukünftige Verwendung enthält, wird hier keine Widerrufslogik bereitgestellt.
  • Authorization Code Flow: Wir haben uns ausschließlich auf den pre-authorized_code-Flow konzentriert. Eine vollständige Implementierung des authorization_code-Flows würde einen Zustimmungsbildschirm für den Benutzer und eine komplexere OAuth 2.0-Logik erfordern.
  • Benutzerverwaltung: Der Leitfaden enthält keine Benutzerauthentifizierung oder -verwaltung für den Issuer selbst. Es wird angenommen, dass der Benutzer bereits authentifiziert und berechtigt ist, einen Nachweis zu erhalten.

6. Fazit#

Das war's! Mit ein paar Seiten Code haben wir nun einen vollständigen End-to-End-Issuer für digitale Nachweise, der:

  1. Ein benutzerfreundliches Frontend zur Anforderung von Nachweisen bereitstellt.
  2. Den vollständigen OpenID4VCI pre-authorized_code-Flow implementiert.
  3. Alle notwendigen Discovery-Endpunkte für die Interoperabilität mit Wallets bereitstellt.
  4. Einen sicheren, standardkonformen JWT-Verifiable Credential generiert und signiert.

Obwohl dieser Leitfaden eine solide Grundlage bietet, würde ein produktionsreifer Issuer zusätzliche Funktionen wie robustes Schlüsselmanagement, persistenten Speicher anstelle von In-Memory-Speichern, Widerruf von Nachweisen und eine umfassende Sicherheitshärtung erfordern. Die Kompatibilität mit Wallets variiert ebenfalls; die Sphereon Wallet wird zum Testen empfohlen, aber andere Wallets unterstützen den hier implementierten Pre-Authorized-Flow möglicherweise nicht. Die Kernbausteine und der Interaktionsfluss würden jedoch gleich bleiben. Indem Sie diesen Mustern folgen, können Sie einen sicheren und interoperablen Issuer für jede Art von digitalem Nachweis erstellen.

7. Ressourcen#

Hier sind einige der wichtigsten Ressourcen, Spezifikationen und Tools, die in diesem Tutorial verwendet oder referenziert wurden:

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

Start Free Trial

Share this article


LinkedInTwitterFacebook