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

Come creare un Issuer di credenziali digitali (Guida per sviluppatori)

Scopri come creare un issuer di Credenziali Verificabili W3C utilizzando il protocollo OpenID4VCI. Questa guida passo-passo mostra come creare un'applicazione Next.js che emette credenziali firmate crittograficamente e compatibili con i wallet digitali.

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. Introduzione#

Le credenziali digitali sono un modo efficace per comprovare l'identità e le attestazioni in maniera sicura e rispettosa della privacy. Ma come fanno gli utenti a ottenere queste credenziali? È qui che il ruolo dell'Issuer diventa cruciale. Un Issuer è un'entità fidata, come un'agenzia governativa, un'università o una banca, responsabile della creazione e della distribuzione di credenziali firmate digitalmente agli utenti.

Questa guida offre un tutorial completo e passo-passo per la creazione di un Issuer di credenziali digitali. Ci concentreremo sul protocollo OpenID for Verifiable Credential Issuance (OpenID4VCI), uno standard moderno che definisce come gli utenti possono ottenere credenziali da un Issuer e conservarle in modo sicuro nei loro wallet digitali.

Il risultato finale sarà un'applicazione Next.js funzionante in grado di:

  1. Accettare i dati dell'utente tramite un semplice modulo web.
  2. Generare un'offerta di credenziale sicura e monouso.
  3. Mostrare l'offerta come codice QR che l'utente può scansionare con il suo wallet mobile.
  4. Emettere una credenziale firmata crittograficamente che l'utente può conservare e presentare per la verifica.

1.1 Capire la terminologia: credenziali digitali e credenziali verificabili#

Prima di procedere, è importante chiarire la distinzione tra due concetti correlati ma diversi:

  • Credenziali digitali (termine generico): si tratta di una categoria ampia che comprende qualsiasi forma digitale di credenziali, certificati o attestazioni. Possono includere semplici certificati digitali, digital badge di base o qualsiasi credenziale archiviata elettronicamente che può avere o meno funzioni di sicurezza crittografica.

  • Credenziali verificabili (VC - Standard W3C): si tratta di un tipo specifico di credenziale digitale che segue lo standard W3C Verifiable Credentials Data Model. Le credenziali verificabili sono credenziali firmate crittograficamente, a prova di manomissione e rispettose della privacy che possono essere verificate in modo indipendente. Includono requisiti tecnici specifici come:

    • Firme crittografiche per l'autenticità e l'integrità
    • Modello di dati e formati standardizzati
    • Meccanismi di presentazione che tutelano la privacy
    • Protocolli di verifica interoperabili

In questa guida, stiamo creando specificamente un issuer di credenziali verificabili che segue lo standard W3C, non un qualsiasi sistema di credenziali digitali. Il protocollo OpenID4VCI che stiamo usando è progettato specificamente per l'emissione di credenziali verificabili e il formato JWT-VC che implementeremo è un formato conforme a W3C per le credenziali verificabili.

1.2 Come funziona#

La magia dietro le credenziali digitali risiede in un modello semplice ma potente a "triangolo della fiducia" che coinvolge tre attori chiave:

  • Issuer: un'autorità fidata (ad esempio un'agenzia governativa, un'università o una banca) che firma crittograficamente ed emette una credenziale a un utente. Questo è il ruolo che stiamo creando in questa guida.
  • Holder: l'utente, che riceve la credenziale e la conserva in modo sicuro in un wallet digitale personale sul suo dispositivo.
  • Verifier: un'applicazione o un servizio che deve controllare la credenziale dell'utente.

Il flusso di emissione è il primo passo in questo ecosistema. L'Issuer convalida le informazioni dell'utente e gli fornisce una credenziale. Una volta che l'Holder ha questa credenziale nel suo wallet, può presentarla a un Verifier per dimostrare la sua identità o le sue attestazioni, completando il triangolo.

Ecco una rapida panoramica dell'applicazione finale in azione:

Passaggio 1: inserimento dei dati dell'utente L'utente compila un modulo con le sue informazioni personali per richiedere una nuova credenziale.

Passaggio 2: generazione dell'offerta di credenziali L'applicazione genera un'offerta di credenziali sicura, visualizzata come codice QR e un codice pre-autorizzato.

Passaggio 3: interazione con il wallet L'utente scansiona il codice QR con un wallet compatibile (ad esempio, Sphereon Wallet) e inserisce un PIN per autorizzare l'emissione.

Passaggio 4: emissione della credenziale Il wallet riceve e archivia la credenziale digitale appena emessa, pronta per un uso futuro.

2. Prerequisiti per creare un Issuer#

Prima di immergerci nel codice, esaminiamo le conoscenze e gli strumenti fondamentali di cui avrai bisogno. Questa guida presuppone una familiarità di base con i concetti di sviluppo web, ma i seguenti prerequisiti sono essenziali per creare un issuer di credenziali.

2.1 Scelta dei protocolli#

Il nostro Issuer si basa su una serie di standard aperti che garantiscono l'interoperabilità tra wallet e servizi di emissione. Per questo tutorial, ci concentreremo sui seguenti:

Standard / ProtocolloDescrizione
OpenID4VCIOpenID for Verifiable Credential Issuance. È il protocollo principale che useremo. Definisce un flusso standard per il modo in cui un utente (tramite il suo wallet) può richiedere e ricevere una credenziale da un Issuer.
JWT-VCCredenziali verificabili basate su JWT. Il formato per la credenziale che emetteremo. È uno standard W3C che codifica le credenziali verificabili come JSON Web Token (JWT), rendendole compatte e adatte al web.
ISO mDocISO/IEC 18013-5. Lo standard internazionale per le patenti di guida mobili (mDL). Sebbene emettiamo una JWT-VC, i claims al suo interno sono strutturati per essere compatibili con il modello di dati mDoc (ad esempio eu.europa.ec.eudi.pid.1).
OAuth 2.0Il framework di autorizzazione sottostante utilizzato da OpenID4VCI. Implementeremo un flusso pre-authorized_code, che è un tipo di grant specifico progettato per un'emissione di credenziali sicura e di facile utilizzo.

2.1.1 Flussi di autorizzazione: Pre-Authorized e Authorization Code#

OpenID4VCI supporta due flussi di autorizzazione principali per l'emissione di credenziali:

  1. Flusso Pre-Authorized Code: in questo flusso, l'Issuer genera un codice monouso di breve durata (pre-authorized_code) che è immediatamente disponibile per l'utente. Il wallet dell'utente può quindi scambiare questo codice direttamente con una credenziale. Questo flusso è ideale per gli scenari in cui l'utente è già autenticato e presente sul sito web dell'Issuer, poiché fornisce un'esperienza di emissione istantanea e senza interruzioni, senza reindirizzamenti.

  2. Flusso Authorization Code: è il flusso standard di OAuth 2.0, in cui l'utente viene reindirizzato a un server di autorizzazione per concedere il consenso. Dopo l'approvazione, il server invia un authorization_code a un redirect_uri registrato. Questo flusso è più adatto per le applicazioni di terze parti che avviano il processo di emissione per conto dell'utente.

Per questo tutorial, useremo il flusso pre-authorized_code. Abbiamo scelto questo approccio perché è più semplice e offre un'esperienza utente più diretta per il nostro caso d'uso specifico: un utente che richiede direttamente una credenziale dal sito web dell'Issuer stesso. Elimina la necessità di complessi reindirizzamenti e registrazioni del client, rendendo la logica di emissione di base più facile da capire e implementare.

Questa combinazione di standard ci permette di creare un issuer compatibile con una vasta gamma di wallet digitali e garantisce un processo sicuro e standardizzato per l'utente.

2.2 Scelta dello stack tecnologico#

Per creare il nostro issuer, useremo lo stesso stack tecnologico solido e moderno che abbiamo usato per il verifier, garantendo un'esperienza di sviluppo coerente e di alta qualità.

2.2.1 Linguaggio: TypeScript#

Useremo TypeScript sia per il nostro codice frontend che per quello backend. La sua tipizzazione statica è preziosa in un'applicazione critica per la sicurezza come un issuer, poiché aiuta a prevenire errori comuni e migliora la qualità e la manutenibilità complessiva del codice.

2.2.2 Framework: Next.js#

Next.js è il nostro framework preferito perché offre un'esperienza integrata e senza interruzioni per la creazione di applicazioni full-stack.

  • Per il frontend: useremo Next.js con React per creare l'interfaccia utente in cui gli utenti possono inserire i loro dati per richiedere una credenziale.
  • Per il backend: sfrutteremo le API Routes di Next.js per creare gli endpoint lato server che gestiscono il flusso OpenID4VCI, dalla generazione delle offerte di credenziali all'emissione della credenziale firmata finale.

2.2.3 Librerie principali#

La nostra implementazione si baserà su alcune librerie chiave per gestire attività specifiche:

  • next, react e react-dom: le librerie principali per la nostra applicazione Next.js.
  • mysql2: un client MySQL per Node.js, usato per archiviare i codici di autorizzazione e i dati di sessione.
  • uuid: una libreria per generare identificatori unici, che useremo per creare i valori pre-authorized_code.
  • jose: una solida libreria per la gestione delle JSON Web Signatures (JWS), che useremo per firmare crittograficamente le credenziali che emettiamo.

2.3 Ottenere un wallet di prova#

Per testare il tuo issuer, avrai bisogno di un wallet mobile che supporti il protocollo OpenID4VCI. Per questo tutorial, consigliamo lo Sphereon Wallet, disponibile sia per Android che per iOS.

Come installare Sphereon Wallet:

  1. Scarica il wallet dal Google Play Store o dall'Apple App Store.
  2. Installa l'app sul tuo dispositivo mobile.
  3. Una volta installato, il wallet è pronto per ricevere offerte di credenziali scansionando un codice QR.

2.4 Conoscenze di crittografia#

L'emissione di una credenziale è un'operazione critica per la sicurezza che si basa su concetti crittografici fondamentali per garantire fiducia e autenticità.

2.4.1 Firme digitali#

Nella sua essenza, una credenziale verificabile è un insieme di attestazioni che sono state firmate digitalmente dall'Issuer. Questa firma fornisce due garanzie:

  • Autenticità: dimostra che la credenziale è stata creata da un Issuer legittimo.
  • Integrità: dimostra che la credenziale non è stata manomessa da quando è stata emessa.

2.4.2 Crittografia a chiave pubblica/privata#

Le firme digitali vengono create utilizzando la crittografia a chiave pubblica/privata. Ecco come funziona:

  1. L'Issuer ha una coppia di chiavi: una chiave privata, che viene mantenuta segreta e sicura, e una chiave pubblica corrispondente, che viene resa pubblicamente disponibile.
  2. Firma: quando l'Issuer crea una credenziale, usa la sua chiave privata per generare una firma digitale unica per i dati della credenziale.
  3. Verifica: un Verifier può successivamente usare la chiave pubblica dell'Issuer per controllare la firma. Se il controllo ha esito positivo, il Verifier sa che la credenziale è autentica e non è stata alterata.

Nella nostra implementazione, genereremo una coppia di chiavi Elliptic Curve (EC) e useremo l'algoritmo ES256 per firmare la JWT-VC. La chiave pubblica è incorporata nel DID dell'Issuer (did:web), consentendo a qualsiasi Verifier di scoprirla e convalidare la firma della credenziale.
Nota: il claim aud (audience) è intenzionalmente omesso nei nostri JWT, poiché la credenziale è progettata per essere di uso generale e non legata a un wallet specifico.
Se vuoi limitare l'uso a un pubblico particolare, includi un claim aud e impostalo di conseguenza.

3. Panoramica dell'architettura#

La nostra applicazione Issuer è costruita come un progetto Next.js full-stack, con una chiara separazione tra la logica del frontend e quella del backend. Questa architettura ci permette di creare un'esperienza utente fluida gestendo al contempo tutte le operazioni critiche per la sicurezza sul server.
Importante: le tabelle verification_sessions e verified_credentials incluse nell'SQL non sono necessarie per questo issuer, ma sono incluse per completezza.

  • Frontend (src/app/issue/page.tsx): una singola pagina React che permette agli utenti di inserire i loro dati per richiedere una credenziale. Effettua chiamate API al nostro backend per avviare il processo di emissione.
  • Backend API Routes (src/app/api/issue/...): un insieme di endpoint lato server che implementano il protocollo OpenID4VCI.
    • /.well-known/openid-credential-issuer: un endpoint pubblico di metadati. Questo è il primo URL che un wallet controllerà per scoprire le capacità dell'issuer, inclusi il suo server di autorizzazione, l'endpoint del token, l'endpoint delle credenziali e i tipi di credenziali che offre.
    • /.well-known/openid-configuration: un endpoint di discovery standard di OpenID Connect. Sebbene strettamente correlato a quello precedente, questo endpoint serve una configurazione più ampia legata a OIDC ed è spesso richiesto per l'interoperabilità con i client OpenID standard.
    • /.well-known/did.json: il DID Document per il nostro issuer. Quando si usa il metodo did:web, questo file viene usato per pubblicare le chiavi pubbliche dell'issuer, che i verifier possono usare per convalidare le firme delle credenziali che emette.
    • authorize/route.ts: crea un pre-authorized_code e un'offerta di credenziali.
    • token/route.ts: scambia il pre-authorized_code con un access token.
    • credential/route.ts: emette la JWT-VC finale, firmata crittograficamente.
    • schemas/pid/route.ts: espone lo schema JSON per la credenziale PID. Ciò consente a qualsiasi consumatore della credenziale di comprenderne la struttura e i tipi di dati.
  • Libreria (src/lib/):
    • database.ts: gestisce tutte le interazioni con il database, come l'archiviazione dei codici di autorizzazione e delle chiavi dell'issuer.
    • crypto.ts: gestisce tutte le operazioni crittografiche, inclusa la generazione di chiavi e la firma di JWT.

Ecco un diagramma che illustra il flusso di emissione:

4. Creazione dell'Issuer#

Ora che abbiamo una solida comprensione degli standard, dei protocolli e dell'architettura, possiamo iniziare a creare il nostro issuer.

Segui i passaggi o usa il codice finale

Ora esamineremo passo dopo passo la configurazione e l'implementazione del codice. Se preferisci passare direttamente al prodotto finito, puoi clonare il progetto completo dal nostro repository GitHub ed eseguirlo localmente.

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

4.1 Impostazione del progetto#

Per prima cosa, inizializzeremo un nuovo progetto Next.js, installeremo le dipendenze necessarie e avvieremo il nostro database.

4.1.1 Inizializzazione dell'app Next.js#

Apri il tuo terminale, vai alla directory in cui vuoi creare il tuo progetto ed esegui il seguente comando. Per questo progetto stiamo usando l'App Router, TypeScript e Tailwind CSS.

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

Questo comando crea lo scaffolding di una nuova applicazione Next.js nella tua directory corrente.

4.1.2 Installazione delle dipendenze#

Successivamente, dobbiamo installare le librerie che gestiranno i JWT, le connessioni al database e la generazione di UUID.

npm install jose mysql2 uuid @types/uuid

Questo comando installa:

  • jose: per firmare e verificare i JSON Web Token (JWT).
  • mysql2: il client MySQL per il nostro database.
  • uuid: per generare stringhe di challenge uniche.
  • @types/uuid: i tipi TypeScript per la libreria uuid.

4.1.3 Avvio del database#

Il nostro backend richiede un database MySQL per archiviare i codici di autorizzazione, le sessioni di emissione e le chiavi dell'issuer. Abbiamo incluso un file docker-compose.yml per semplificare questa operazione.

Se hai clonato il repository, puoi semplicemente eseguire docker-compose up -d. Se stai costruendo da zero, crea un file chiamato docker-compose.yml con il seguente contenuto:

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:

Questa configurazione di Docker Compose richiede anche uno script di inizializzazione SQL. Crea una directory chiamata sql e al suo interno un file chiamato init.sql con il seguente contenuto per configurare le tabelle necessarie sia per il verifier che per l'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) );

Una volta che entrambi i file sono a posto, apri il tuo terminale nella radice del progetto ed esegui:

docker-compose up -d

Questo comando avvierà un container MySQL in background, pronto per essere usato dalla nostra applicazione.

4.2 Implementazione delle librerie condivise#

Prima di creare gli endpoint API, creiamo le librerie condivise che gestiranno la logica di business principale. Questo approccio mantiene le nostre API routes pulite e focalizzate sulla gestione delle richieste HTTP, mentre il lavoro complesso è delegato a questi moduli.

4.2.1 La libreria del database (src/lib/database.ts)#

Questo file è l'unica fonte di verità per tutte le interazioni con il database. Usa la libreria mysql2 per connettersi al nostro container MySQL e fornisce un insieme di funzioni esportate per creare, leggere e aggiornare i record nelle nostre tabelle. Questo strato di astrazione rende il nostro codice più modulare e più facile da mantenere.

Crea il file src/lib/database.ts con il seguente contenuto:

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

Nota: per brevità, l'elenco completo delle funzioni DAO è stato omesso. Puoi trovare il codice completo nel repository del progetto. Questo file include funzioni per la gestione di challenge, sessioni di verifica, codici di autorizzazione, sessioni di emissione e chiavi dell'issuer.

4.2.2 La libreria di crittografia (src/lib/crypto.ts)#

Questo file gestisce tutte le operazioni crittografiche critiche per la sicurezza. Usa la libreria jose per generare coppie di chiavi e firmare JSON Web Token (JWT).

Generazione delle chiavi La funzione generateIssuerKeyPair crea una nuova coppia di chiavi Elliptic Curve che verrà usata per firmare le credenziali. La chiave pubblica è esportata in formato JSON Web Key (JWK) in modo da poter essere pubblicata nel nostro documento 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 /* ... */ }; }

Creazione di credenziali JWT La funzione createJWTVerifiableCredential è il cuore del processo di emissione. Prende i claim dell'utente, la coppia di chiavi dell'issuer e altri metadati e li usa per creare una JWT-VC firmata.

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

Questa funzione costruisce il payload JWT secondo il W3C Verifiable Credentials Data Model e lo firma con la chiave privata dell'issuer, producendo una credenziale verificabile sicura.

4.2 Panoramica dell'architettura dell'app Next.js#

La nostra applicazione Next.js è strutturata per separare le responsabilità tra frontend e backend, anche se fanno parte dello stesso progetto. Ciò si ottiene sfruttando l'App Router sia per le pagine dell'interfaccia utente che per gli endpoint API.

  • Frontend (src/app/issue/page.tsx): un singolo componente di pagina React che definisce l'interfaccia utente per la route /issue. Gestisce l'input dell'utente e comunica con la nostra API di backend.

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

    • Discovery (.well-known/.../route.ts): queste routes espongono endpoint di metadati pubblici che consentono ai wallet e ad altri client di scoprire le capacità e le chiavi pubbliche dell'issuer.
    • Issuance (issue/.../route.ts): questi endpoint implementano la logica principale di OpenID4VCI, inclusa la creazione di offerte di credenziali, l'emissione di token e la firma della credenziale finale.
    • Schema (schemas/pid/route.ts): questa route serve lo schema JSON per la credenziale, definendone la struttura.
  • Libreria (src/lib/): questa directory contiene la logica riutilizzabile condivisa in tutto il backend.

    • database.ts: gestisce tutte le interazioni con il database, astraendo le query SQL.
    • crypto.ts: gestisce tutte le operazioni crittografiche, come la generazione di chiavi e la firma di JWT.

Questa chiara separazione rende l'applicazione modulare e più facile da mantenere.

Nota: la funzione generateIssuerDid() deve restituire un did:web valido che corrisponda al dominio del tuo issuer.
Quando viene distribuito, il file .well-known/did.json deve essere servito su HTTPS a quel dominio affinché i verifier possano convalidare le credenziali.

4.3 Creazione del frontend#

Il nostro frontend è una singola pagina React che fornisce un semplice modulo per consentire agli utenti di richiedere una nuova credenziale digitale. Le sue responsabilità sono:

  • Acquisire i dati dell'utente (nome, data di nascita, ecc.).
  • Inviare questi dati al nostro backend per creare un'offerta di credenziali.
  • Visualizzare il codice QR e il PIN risultanti affinché l'utente possa scansionarli con il suo wallet.

La logica principale è gestita nella funzione handleSubmit, che viene attivata quando l'utente invia il modulo.

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

Questa funzione esegue tre azioni chiave:

  1. Convalida i dati del modulo per garantire che tutti i campi obbligatori siano compilati.
  2. Invia una richiesta POST al nostro endpoint /api/issue/authorize con i dati dell'utente.
  3. Aggiorna lo stato del componente con l'offerta di credenziali ricevuta dal backend, che fa sì che l'interfaccia utente visualizzi il codice QR e il codice di transazione.

Il resto del file contiene codice React standard per il rendering del modulo e la visualizzazione del codice QR. Puoi visualizzare il file completo nel repository del progetto.

4.4 Impostazione dell'ambiente e del discovery#

Prima di creare l'API di backend, dobbiamo configurare il nostro ambiente e impostare gli endpoint di discovery. Questi file .well-known sono fondamentali affinché i wallet possano trovare il nostro issuer e capire come interagire con esso.

4.4.1 Creare il file di ambiente#

Crea un file chiamato .env.local nella radice del tuo progetto e aggiungi la seguente riga. Questo URL deve essere accessibile pubblicamente affinché un wallet mobile possa raggiungerlo. Per lo sviluppo locale, puoi usare un servizio di tunneling come ngrok per esporre il tuo localhost.

NEXT_PUBLIC_BASE_URL=http://localhost:3000

4.4.2 Implementare gli endpoint di discovery#

I wallet scoprono le capacità di un issuer interrogando URL standard .well-known. Dobbiamo creare tre di questi endpoint.

1. Metadati dell'issuer (/.well-known/openid-credential-issuer)

Questo è il file di discovery principale per OpenID4VCI. Dice al wallet tutto ciò che deve sapere sull'issuer, inclusi i suoi endpoint, i tipi di credenziali che offre e gli algoritmi crittografici supportati.

Crea il file 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. Configurazione OpenID (/.well-known/openid-configuration)

Questo è un documento di discovery OIDC standard che fornisce un insieme più ampio di dettagli di configurazione.

Crea il file 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. Documento DID (/.well-known/did.json)

Questo file pubblica la chiave pubblica dell'issuer usando il metodo did:web, consentendo a chiunque di verificare la firma delle credenziali emesse da esso.

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

Perché non usare la cache? Noterai che tutti e tre questi endpoint restituiscono intestazioni che impediscono aggressivamente la memorizzazione nella cache (Cache-Control: no-cache, Pragma: no-cache, Expires: 0). Questa è una pratica di sicurezza fondamentale per i documenti di discovery. Le configurazioni dell'issuer possono cambiare, ad esempio, una chiave crittografica potrebbe essere ruotata. Se un wallet o un client memorizzasse nella cache una vecchia versione del file did.json o openid-credential-issuer, non riuscirebbe a convalidare nuove credenziali o a interagire con endpoint aggiornati. Forzando i client a recuperare una copia aggiornata a ogni richiesta, ci assicuriamo che abbiano sempre le informazioni più recenti.

4.4.3 Implementare l'endpoint dello schema delle credenziali#

L'ultimo pezzo della nostra infrastruttura pubblica è l'endpoint dello schema delle credenziali. Questa route serve uno schema JSON che definisce formalmente la struttura, i tipi di dati e i vincoli della credenziale PID che stiamo emettendo. Wallet e verifier possono usare questo schema per convalidare il contenuto della credenziale.

Crea il file src/app/api/schemas/pid/route.ts con il seguente contenuto:

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

Nota: lo schema JSON per una credenziale PID può essere piuttosto grande e dettagliato. Per brevità, lo schema completo è stato troncato. Puoi trovare il file completo nel repository del progetto.

4.5 Creazione degli endpoint di backend#

Con il frontend a posto, ora abbiamo bisogno della logica lato server per gestire il flusso OpenID4VCI. Inizieremo con il primo endpoint che il frontend chiama: /api/issue/authorize.

4.5.1 /api/issue/authorize: creare l'offerta di credenziali#

Questo endpoint è responsabile di prendere i dati dell'utente, generare un codice monouso sicuro e costruire un'credential_offer che il wallet dell'utente possa capire.

Ecco la logica principale:

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

Passaggi chiave in questo endpoint:

  1. Convalida dei dati: per prima cosa, si assicura che i dati utente richiesti siano presenti.
  2. Generazione di codici: crea un pre-authorized_code univoco (un UUID) e un tx_code a 4 cifre (PIN) per un ulteriore livello di sicurezza.
  3. Persistenza dei dati: il pre-authorized_code viene archiviato nel database con un breve tempo di scadenza. I dati dell'utente e il PIN vengono archiviati in memoria, collegati al codice.
  4. Costruzione dell'offerta: costruisce l'oggetto credential_offer secondo le specifiche OpenID4VCI. Questo oggetto indica al wallet dove si trova l'issuer, quali credenziali offre e il codice necessario per ottenerle.
  5. Restituzione dell'URI: infine, crea un URI di deep link (openid-credential-offer://...) e lo restituisce al frontend, insieme al tx_code affinché l'utente lo veda.

4.5.2 /api/issue/token: scambiare il codice con un token#

Una volta che l'utente scansiona il codice QR e inserisce il suo PIN, il wallet effettua una richiesta POST a questo endpoint. Il suo compito è convalidare il pre-authorized_code e lo user_pin (PIN) e, se sono validi, emettere un access token di breve durata.

Crea il file src/app/api/issue/token/route.ts con il seguente contenuto:

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

Passaggi chiave in questo endpoint:

  1. Convalida del tipo di grant: si assicura che il wallet stia usando il tipo di grant pre-authorized_code corretto.
  2. Convalida del codice: controlla che il pre-authorized_code esista nel database, non sia scaduto e non sia stato usato prima.
  3. Convalida del PIN: confronta lo user_pin del wallet con il tx_code che abbiamo archiviato in precedenza per garantire che l'utente abbia autorizzato la transazione.
  4. Generazione di token: crea un access_token sicuro e un c_nonce (credential nonce), che è un valore monouso per prevenire attacchi di replay sull'endpoint della credenziale.
  5. Creazione della sessione: crea un nuovo record issuance_sessions nel database, collegando l'access token ai dati dell'utente.
  6. Contrassegno del codice come usato: per evitare che la stessa offerta venga usata due volte, contrassegna il pre-authorized_code come usato.
  7. Restituzione del token: restituisce l'access_token e il c_nonce al wallet.

4.5.3 /api/issue/credential: emettere la credenziale firmata#

Questo è l'endpoint finale e più importante. Il wallet usa l'access token che ha ricevuto dall'endpoint /token per effettuare una richiesta POST autenticata a questa route. Il compito di questo endpoint è eseguire la convalida finale, creare la credenziale firmata crittograficamente e restituirla al wallet.

Crea il file src/app/api/issue/credential/route.ts con il seguente contenuto:

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

Passaggi chiave in questo endpoint:

  1. Convalida del token: controlla la presenza di un token Bearer valido nell'intestazione Authorization e lo usa per cercare la sessione di emissione attiva.
  2. Recupero dei dati utente: recupera i dati dei claim dell'utente, che sono stati archiviati nella sessione quando il token è stato creato.
  3. Caricamento della chiave dell'issuer: carica la chiave di firma attiva dell'issuer dal database. In uno scenario reale, questa sarebbe gestita da un sistema di gestione delle chiavi sicuro.
  4. Creazione della credenziale: chiama il nostro helper createJWTVerifiableCredential da src/lib/crypto.ts per costruire e firmare la JWT-VC.
  5. Registrazione dell'emissione: salva un record della credenziale emessa nel database per scopi di auditing e revoca.
  6. Restituzione della credenziale: restituisce la credenziale firmata al wallet in una risposta JSON. Il wallet è quindi responsabile della sua archiviazione sicura.

5. Esecuzione dell'Issuer e passi successivi#

Hai ora un'implementazione completa e end-to-end di un issuer di credenziali digitali. Ecco come eseguirlo localmente e cosa devi considerare per portarlo da una proof-of-concept a un'applicazione pronta per la produzione.

5.1 Come eseguire l'esempio#

  1. Clona il repository:

    git clone https://github.com/corbado/digital-credentials-example.git cd digital-credentials-example
  2. Installa le dipendenze:

    npm install
  3. Avvia il database: assicurati che Docker sia in esecuzione, quindi avvia il container MySQL:

    docker-compose up -d
  4. Configura l'ambiente ed esegui il tunnel: questo è il passaggio più critico per i test locali. Poiché il tuo wallet mobile deve connettersi alla tua macchina di sviluppo tramite Internet, devi esporre il tuo server locale con un URL HTTPS pubblico. Per questo, useremo ngrok.

    a. Avvia ngrok:

    ngrok http 3000

    b. Copia l'URL HTTPS dall'output di ngrok (ad esempio https://stringa-casuale.ngrok.io). c. Crea un file .env.local e imposta l'URL:

    NEXT_PUBLIC_BASE_URL=https://<your-ngrok-url>
  5. Esegui l'applicazione:

    npm run dev

    Apri il tuo browser all'indirizzo http://localhost:3000/issue. Ora puoi compilare il modulo e il codice QR generato punterà correttamente al tuo URL pubblico di ngrok, consentendo al tuo wallet mobile di connettersi e ricevere la credenziale.

5.2 L'importanza di HTTPS e ngrok#

I protocolli delle credenziali digitali sono costruiti con la sicurezza come massima priorità. Per questo motivo, i wallet si rifiuteranno quasi sempre di connettersi a un issuer tramite una connessione non sicura (http://). L'intero processo si basa su una connessione HTTPS sicura, abilitata da un certificato SSL.

Un servizio di tunnel come ngrok risolve entrambi i problemi creando un URL HTTPS pubblico e sicuro (con un certificato SSL valido) che inoltra tutto il traffico al tuo server di sviluppo locale.
I wallet richiedono HTTPS e si rifiuteranno di connettersi a endpoint non sicuri (http://). Questo è uno strumento essenziale per testare qualsiasi servizio web che deve interagire con dispositivi mobili o webhook esterni.

5.3 Cosa non è trattato in questo tutorial#

Questo esempio è intenzionalmente focalizzato sul flusso di emissione principale per renderlo facile da capire. I seguenti argomenti sono considerati fuori dall'ambito di applicazione:

  • Sicurezza pronta per la produzione: l'issuer è a scopo educativo. Un sistema di produzione richiederebbe un Key Management System (KMS) sicuro invece di archiviare le chiavi in un database, una solida gestione degli errori, il rate-limiting e una registrazione di audit completa.
  • Revoca delle credenziali: questa guida non implementa un meccanismo per revocare le credenziali emesse.
    Mentre lo schema include un flag revoked per un uso futuro, qui non viene fornita alcuna logica di revoca.
  • Flusso Authorization Code: ci siamo concentrati esclusivamente sul flusso pre-authorized_code. Un'implementazione completa del flusso authorization_code richiederebbe una schermata di consenso dell'utente e una logica OAuth 2.0 più complessa.
  • Gestione degli utenti: la guida non include alcuna autenticazione o gestione degli utenti per l'issuer stesso. Si presume che l'utente sia già autenticato e autorizzato a ricevere una credenziale.

6. Conclusione#

Ecco fatto! Con poche pagine di codice, ora abbiamo un issuer di credenziali digitali completo e end-to-end che:

  1. Fornisce un frontend di facile utilizzo per la richiesta di credenziali.
  2. Implementa il flusso completo OpenID4VCI pre-authorized_code.
  3. Espone tutti gli endpoint di discovery necessari per l'interoperabilità del wallet.
  4. Genera e firma una credenziale verificabile JWT sicura e conforme agli standard.

Sebbene questa guida fornisca una solida base, un issuer pronto per la produzione richiederebbe funzionalità aggiuntive come una solida gestione delle chiavi, archiviazione persistente invece di archivi in memoria, revoca delle credenziali e un completo rafforzamento della sicurezza.
Anche la compatibilità dei wallet varia; si consiglia lo Sphereon Wallet per i test, ma altri wallet potrebbero non supportare il flusso pre-autorizzato come implementato qui. Tuttavia, i blocchi di costruzione principali e il flusso di interazione rimarrebbero gli stessi. Seguendo questi schemi, puoi costruire un issuer sicuro e interoperabile per qualsiasi tipo di credenziale digitale.

7. Risorse#

Ecco alcune delle risorse chiave, delle specifiche e degli strumenti usati o citati in questo tutorial:

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

Start Free Trial

Share this article


LinkedInTwitterFacebook