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
See the original blog version in English here.
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:
Recent Articles
📝
Come creare un Issuer di credenziali digitali (Guida per sviluppatori)
📝
Come creare un Verifier di credenziali digitali (Guida per sviluppatori)
📖
Chiave Residente WebAuthn: Credenziali Individuabili come Passkey
🔑
Accesso con badge fisico e passkey: guida tecnica
🔑
Rendere obbligatoria la MFA e passare alle Passkey: Best Practice
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:
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.
La magia dietro le credenziali digitali risiede in un modello semplice ma potente a "triangolo della fiducia" che coinvolge tre attori chiave:
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.
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.
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 / Protocollo | Descrizione |
---|---|
OpenID4VCI | OpenID 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-VC | Credenziali 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 mDoc | ISO/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.0 | Il 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. |
OpenID4VCI supporta due flussi di autorizzazione principali per l'emissione di credenziali:
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.
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.
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à.
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.
Next.js è il nostro framework preferito perché offre un'esperienza integrata e senza interruzioni per la creazione di applicazioni full-stack.
La nostra implementazione si baserà su alcune librerie chiave per gestire attività specifiche:
pre-authorized_code
.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:
L'emissione di una credenziale è un'operazione critica per la sicurezza che si basa su concetti crittografici fondamentali per garantire fiducia e autenticità.
Nella sua essenza, una credenziale verificabile è un insieme di attestazioni che sono state firmate digitalmente dall'Issuer. Questa firma fornisce due garanzie:
Le firme digitali vengono create utilizzando la crittografia a chiave pubblica/privata. Ecco come funziona:
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.
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.
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.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.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:
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
Per prima cosa, inizializzeremo un nuovo progetto Next.js, installeremo le dipendenze necessarie e avvieremo il nostro database.
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.
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
.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.
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.
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.
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.
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/...
):
.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.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.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.
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:
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:
POST
al nostro endpoint /api/issue/authorize
con i dati
dell'utente.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.
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.
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
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.
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.
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
.
/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:
pre-authorized_code
univoco (un UUID) e un
tx_code
a 4 cifre (PIN) per un ulteriore livello di sicurezza.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.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.openid-credential-offer://...
) e lo restituisce al frontend, insieme al tx_code
affinché l'utente lo veda./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:
pre-authorized_code
corretto.pre-authorized_code
esista nel database,
non sia scaduto e non sia stato usato prima.user_pin
del wallet con il tx_code
che abbiamo
archiviato in precedenza per garantire che l'utente abbia autorizzato la transazione.access_token
sicuro e un c_nonce
(credential
nonce), che è un valore monouso per prevenire attacchi di replay sull'endpoint della
credenziale.issuance_sessions
nel database,
collegando l'access token ai dati dell'utente.pre-authorized_code
come usato.access_token
e il c_nonce
al wallet./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:
Bearer
valido
nell'intestazione Authorization
e lo usa per cercare la sessione di emissione attiva.createJWTVerifiableCredential
da src/lib/crypto.ts
per costruire e firmare la
JWT-VC.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.
Clona il repository:
git clone https://github.com/corbado/digital-credentials-example.git cd digital-credentials-example
Installa le dipendenze:
npm install
Avvia il database: assicurati che Docker sia in esecuzione, quindi avvia il container MySQL:
docker-compose up -d
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>
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.
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.
Questo esempio è intenzionalmente focalizzato sul flusso di emissione principale per renderlo facile da capire. I seguenti argomenti sono considerati fuori dall'ambito di applicazione:
revoked
per un uso futuro, qui non viene fornita
alcuna logica di revoca.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.Ecco fatto! Con poche pagine di codice, ora abbiamo un issuer di credenziali digitali completo e end-to-end che:
pre-authorized_code
.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.
Ecco alcune delle risorse chiave, delle specifiche e degli strumenti usati o citati in questo tutorial:
Repository del progetto:
Specifiche chiave:
did:web
: il metodo DID
usato per la chiave pubblica del nostro issuer.Strumenti:
Librerie:
Related Articles
Table of Contents