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

Cómo construir un emisor de credenciales digitales (Guía para desarrolladores)

Aprende a construir un emisor de credenciales verificables W3C utilizando el protocolo OpenID4VCI. Esta guía paso a paso te muestra cómo crear una aplicación Next.js que emite credenciales firmadas criptográficamente y compatibles con billeteras digitales

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. Introducción#

Las credenciales digitales son una forma poderosa de probar la identidad y las afirmaciones de manera segura y preservando la privacidad. Pero, ¿cómo obtienen los usuarios estas credenciales en primer lugar? Aquí es donde el papel del Emisor se vuelve crucial. Un Emisor es una entidad de confianza, como una agencia gubernamental, una universidad o un banco, responsable de crear y distribuir credenciales firmadas digitalmente a los usuarios.

Esta guía ofrece un tutorial completo y paso a paso para construir un Emisor de credenciales digitales. Nos centraremos en el protocolo OpenID for Verifiable Credential Issuance (OpenID4VCI), un estándar moderno que define cómo los usuarios pueden obtener credenciales de un Emisor y almacenarlas de forma segura en sus billeteras digitales.

El resultado final será una aplicación funcional de Next.js que puede:

  1. Aceptar datos del usuario a través de un simple formulario web.
  2. Generar una oferta de credencial segura y de un solo uso.
  3. Mostrar la oferta como un código QR para que el usuario la escanee con su billetera móvil.
  4. Emitir una credencial firmada criptográficamente que el usuario puede almacenar y presentar para su verificación.

1.1 Entendiendo la terminología: Credenciales digitales vs. Credenciales verificables#

Antes de continuar, es importante aclarar la distinción entre dos conceptos relacionados pero diferentes:

  • Credenciales digitales (término general): Es una categoría amplia que engloba cualquier forma digital de credenciales, certificados o atestaciones. Pueden incluir certificados digitales simples, insignias digitales básicas o cualquier credencial almacenada electrónicamente que pueda o no tener características de seguridad criptográfica.

  • Credenciales verificables (VCs - Estándar W3C): Es un tipo específico de credencial digital que sigue el estándar del Modelo de Datos de Credenciales Verificables del W3C. Las credenciales verificables son credenciales firmadas criptográficamente, a prueba de manipulaciones y que respetan la privacidad, y que pueden ser verificadas de forma independiente. Incluyen requisitos técnicos específicos como:

    • Firmas criptográficas para autenticidad e integridad
    • Modelo de datos y formatos estandarizados
    • Mecanismos de presentación que preservan la privacidad
    • Protocolos de verificación interoperables

En esta guía, estamos construyendo específicamente un emisor de credenciales verificables que sigue el estándar W3C, no solo un sistema de credenciales digitales cualquiera. El protocolo OpenID4VCI que estamos usando está diseñado específicamente para emitir credenciales verificables, y el formato JWT-VC que implementaremos es un formato compatible con W3C para credenciales verificables.

1.2 Cómo funciona#

La magia detrás de las credenciales digitales reside en un modelo simple pero poderoso de "triángulo de confianza" que involucra a tres actores clave:

  • Emisor: Una autoridad de confianza (por ejemplo, una agencia gubernamental, universidad o banco) que firma criptográficamente y emite una credencial a un usuario. Este es el rol que estamos construyendo en esta guía.
  • Titular (Holder): El usuario, que recibe la credencial y la almacena de forma segura en una billetera digital personal en su dispositivo.
  • Verificador: Una aplicación o servicio que necesita comprobar la credencial del usuario.

El flujo de emisión es el primer paso en este ecosistema. El Emisor valida la información del usuario y le proporciona una credencial. Una vez que el Titular tiene esta credencial en su billetera, puede presentarla a un Verificador para probar su identidad o sus afirmaciones, completando así el triángulo.

Aquí un vistazo rápido a la aplicación final en acción:

Paso 1: Ingreso de datos del usuario El usuario completa un formulario con su información personal para solicitar una nueva credencial.

Paso 2: Generación de la oferta de credencial La aplicación genera una oferta de credencial segura, que se muestra como un código QR y un código preautorizado.

Paso 3: Interacción con la billetera El usuario escanea el código QR con una billetera compatible (por ejemplo, Sphereon Wallet) e introduce un PIN para autorizar la emisión.

Paso 4: Credencial emitida La billetera recibe y almacena la nueva credencial digital emitida, lista para su uso futuro.

2. Prerrequisitos para construir un emisor#

Antes de sumergirnos en el código, cubramos los conocimientos y herramientas fundamentales que necesitaremos. Esta guía asume que tienes una familiaridad básica con los conceptos de desarrollo web, pero los siguientes prerrequisitos son esenciales para construir un emisor de credenciales.

2.1 Elección de protocolos#

Nuestro emisor se basa en un conjunto de estándares abiertos que garantizan la interoperabilidad entre billeteras y servicios de emisión. Para este tutorial, nos centraremos en lo siguiente:

Estándar / ProtocoloDescripción
OpenID4VCIOpenID for Verifiable Credential Issuance. Este es el protocolo central que usaremos. Define un flujo estándar sobre cómo un usuario (a través de su billetera) puede solicitar y recibir una credencial de un Emisor.
JWT-VCCredenciales verificables basadas en JWT. El formato de la credencial que emitiremos. Es un estándar del W3C que codifica las credenciales verificables como JSON Web Tokens (JWT), haciéndolas compactas y amigables para la web.
ISO mDocISO/IEC 18013-5. El estándar internacional para las licencias de conducir móviles (mDL). Aunque emitimos un JWT-VC, las claims dentro de él están estructuradas para ser compatibles con el modelo de datos mDoc (por ejemplo, eu.europa.ec.eudi.pid.1).
OAuth 2.0El marco de autorización subyacente utilizado por OpenID4VCI. Implementaremos un flujo de pre-authorized_code, que es un tipo de concesión específico diseñado para una emisión de credenciales segura y fácil de usar.

2.1.1 Flujos de autorización: Código preautorizado vs. Código de autorización#

OpenID4VCI admite dos flujos de autorización principales para emitir credenciales:

  1. Flujo de código preautorizado: En este flujo, el Emisor genera un código de corta duración y de un solo uso (pre-authorized_code) que está disponible de inmediato para el usuario. La billetera del usuario puede luego intercambiar este código directamente por una credencial. Este flujo es ideal para escenarios donde el usuario ya está autenticado y presente en el sitio web del Emisor, ya que proporciona una experiencia de emisión instantánea y fluida sin redirecciones.

  2. Flujo de código de autorización: Este es el flujo estándar de OAuth 2.0, donde el usuario es redirigido a un servidor de autorización para otorgar su consentimiento. Después de la aprobación, el servidor envía un authorization_code de vuelta a una redirect_uri registrada. Este flujo es más adecuado para aplicaciones de terceros que inician el proceso de emisión en nombre del usuario.

Para este tutorial, usaremos el flujo de pre-authorized_code. Elegimos este enfoque porque es más simple y proporciona una experiencia de usuario más directa para nuestro caso de uso específico: un usuario que solicita directamente una credencial desde el propio sitio web del Emisor. Elimina la necesidad de redirecciones complejas y registro de clientes, lo que facilita la comprensión e implementación de la lógica central de emisión.

Esta combinación de estándares nos permite construir un emisor compatible con una amplia gama de billeteras digitales y garantiza un proceso seguro y estandarizado para el usuario.

2.2 Elección de la pila tecnológica#

Para construir nuestro emisor, utilizaremos la misma pila tecnológica robusta y moderna que usamos para el verificador, asegurando una experiencia de desarrollador consistente y de alta calidad.

2.2.1 Lenguaje: TypeScript#

Usaremos TypeScript tanto para nuestro código de frontend como de backend. Su tipado estático es invaluable en una aplicación crítica para la seguridad como un emisor, ya que ayuda a prevenir errores comunes y mejora la calidad y mantenibilidad general del código.

2.2.2 Framework: Next.js#

Next.js es nuestro framework de elección porque proporciona una experiencia fluida e integrada para construir aplicaciones full-stack.

  • Para el frontend: Usaremos Next.js con React para construir la interfaz de usuario donde los usuarios pueden ingresar sus datos para solicitar una credencial.
  • Para el backend: Aprovecharemos las rutas de API de Next.js para crear los endpoints del lado del servidor que manejan el flujo de OpenID4VCI, desde la generación de ofertas de credenciales hasta la emisión de la credencial firmada final.

2.2.3 Librerías clave#

Nuestra implementación se basará en algunas librerías clave para manejar tareas específicas:

  • next, react y react-dom: Las librerías principales para nuestra aplicación Next.js.
  • mysql2: Un cliente de MySQL para Node.js, utilizado para almacenar códigos de autorización y datos de sesión.
  • uuid: Una librería para generar identificadores únicos, que usaremos para crear valores de pre-authorized_code.
  • jose: Una librería robusta para manejar JSON Web Signatures (JWS), que utilizaremos para firmar criptográficamente las credenciales que emitimos.

2.3 Obtén una billetera de prueba#

Para probar tu emisor, necesitarás una billetera móvil que admita el protocolo OpenID4VCI. Para este tutorial, recomendamos la Sphereon Wallet, que está disponible tanto para Android como para iOS.

Cómo instalar Sphereon Wallet:

  1. Descarga la billetera desde la Google Play Store o la Apple App Store.
  2. Instala la aplicación en tu dispositivo móvil.
  3. Una vez instalada, la billetera está lista para recibir ofertas de credenciales escaneando un código QR.

2.4 Conocimientos de criptografía#

Emitir una credencial es una operación crítica para la seguridad que se basa en conceptos criptográficos fundamentales para garantizar la confianza y la autenticidad.

2.4.1 Firmas digitales#

En su núcleo, una credencial verificable es un conjunto de afirmaciones que ha sido firmado digitalmente por el Emisor. Esta firma proporciona dos garantías:

  • Autenticidad: Prueba que la credencial fue creada por un Emisor legítimo.
  • Integridad: Prueba que la credencial no ha sido manipulada desde que fue emitida.

2.4.2 Criptografía de clave pública/privada#

Las firmas digitales se crean utilizando criptografía de clave pública/privada. Así es como funciona:

  1. El Emisor tiene un par de claves: una clave privada, que se mantiene secreta y segura, y una clave pública correspondiente, que se hace pública.
  2. Firma: Cuando el Emisor crea una credencial, utiliza su clave privada para generar una firma digital única para los datos de la credencial.
  3. Verificación: Un Verificador puede usar más tarde la clave pública del Emisor para comprobar la firma. Si la comprobación es exitosa, el Verificador sabe que la credencial es auténtica y no ha sido alterada.

En nuestra implementación, generaremos un par de claves de Curva Elíptica (EC) y usaremos el algoritmo ES256 para firmar el JWT-VC. La clave pública se incrusta en el DID del Emisor (did:web), permitiendo que cualquier Verificador la descubra y valide la firma de la credencial. Nota: La claim aud (audiencia) se omite intencionadamente en nuestros JWTs, ya que la credencial está diseñada para ser de propósito general y no estar vinculada a una billetera específica. Si deseas restringir el uso a una audiencia particular, incluye una claim aud y configúrala en consecuencia.

3. Vista general de la arquitectura#

Nuestra aplicación de emisor está construida como un proyecto full-stack de Next.js, con una clara separación entre la lógica del frontend y del backend. Esta arquitectura nos permite crear una experiencia de usuario fluida mientras manejamos todas las operaciones críticas para la seguridad en el servidor. Importante: Las tablas verification_sessions y verified_credentials incluidas en el SQL no son necesarias para este emisor, pero se incluyen para que esté completo.

  • Frontend (src/app/issue/page.tsx): Una única página de React que permite a los usuarios introducir sus datos para solicitar una credencial. Realiza llamadas a la API de nuestro backend para iniciar el proceso de emisión.
  • Rutas de API del backend (src/app/api/issue/...): Un conjunto de endpoints del lado del servidor que implementan el protocolo OpenID4VCI.
    • /.well-known/openid-credential-issuer: Un endpoint de metadatos público. Esta es la primera URL que una billetera comprobará para descubrir las capacidades del emisor, incluyendo su servidor de autorización, endpoint de token, endpoint de credencial y los tipos de credenciales que ofrece.
    • /.well-known/openid-configuration: Un endpoint de descubrimiento estándar de OpenID Connect. Aunque está estrechamente relacionado con el anterior, este endpoint sirve para una configuración más amplia relacionada con OIDC y a menudo es necesario para la interoperabilidad con clientes OpenID estándar.
    • /.well-known/did.json: El documento DID de nuestro emisor. Al usar el método did:web, este archivo se utiliza para publicar las claves públicas del emisor, que los verificadores pueden usar para validar las firmas de las credenciales que emite.
    • authorize/route.ts: Crea un pre-authorized_code y una oferta de credencial.
    • token/route.ts: Intercambia el pre-authorized_code por un token de acceso.
    • credential/route.ts: Emite el JWT-VC final, firmado criptográficamente.
    • schemas/pid/route.ts: Expone el esquema JSON para la credencial PID. Esto permite que cualquier consumidor de la credencial entienda su estructura y tipos de datos.
  • Librería (src/lib/):
    • database.ts: Gestiona todas las interacciones con la base de datos, como el almacenamiento de códigos de autorización y claves del emisor.
    • crypto.ts: Maneja todas las operaciones criptográficas, incluyendo la generación de claves y la firma de JWT.

Aquí hay un diagrama que ilustra el flujo de emisión:

4. Construyendo el emisor#

Ahora que tenemos una comprensión sólida de los estándares, protocolos y arquitectura, podemos empezar a construir nuestro emisor.

Sigue los pasos o usa el código final

Ahora repasaremos la configuración y la implementación del código paso a paso. Si prefieres ir directamente al producto terminado, puedes clonar el proyecto completo desde nuestro repositorio de GitHub y ejecutarlo localmente.

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

4.1 Configurando el proyecto#

Primero, inicializaremos un nuevo proyecto de Next.js, instalaremos las dependencias necesarias y pondremos en marcha nuestra base de datos.

4.1.1 Inicializando la aplicación Next.js#

Abre tu terminal, navega al directorio donde quieres crear tu proyecto y ejecuta el siguiente comando. Estamos usando el App Router, TypeScript y Tailwind CSS para este proyecto.

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

Este comando crea un esqueleto de una nueva aplicación Next.js en tu directorio actual.

4.1.2 Instalando dependencias#

A continuación, necesitamos instalar las librerías que manejarán los JWTs, las conexiones a la base de datos y la generación de UUIDs.

npm install jose mysql2 uuid @types/uuid

Este comando instala:

  • jose: Para firmar y verificar JSON Web Tokens (JWTs).
  • mysql2: El cliente de MySQL para nuestra base de datos.
  • uuid: Para generar cadenas de desafío únicas.
  • @types/uuid: Tipos de TypeScript para la librería uuid.

4.1.3 Poniendo en marcha la base de datos#

Nuestro backend requiere una base de datos MySQL para almacenar códigos de autorización, sesiones de emisión y claves del emisor. Hemos incluido un archivo docker-compose.yml para facilitar esto.

Si has clonado el repositorio, simplemente puedes ejecutar docker-compose up -d. Si estás construyendo desde cero, crea un archivo llamado docker-compose.yml con el siguiente contenido:

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:

Esta configuración de Docker Compose también requiere un script de inicialización SQL. Crea un directorio llamado sql y dentro de él, un archivo llamado init.sql con el siguiente contenido para configurar las tablas necesarias tanto para el verificador como para el emisor:

-- Crear la base de datos si no existe CREATE DATABASE IF NOT EXISTS digital_credentials; USE digital_credentials; -- Tabla para almacenar desafíos 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) ); -- Tabla para almacenar sesiones de verificación 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) ); -- Tabla para almacenar datos de credenciales verificadas (opcional) 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) ); -- TABLAS DEL EMISOR -- Tabla para almacenar códigos de autorización en el flujo OpenID4VCI 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) ); -- Tabla para almacenar sesiones de emisión 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) ); -- Tabla para almacenar credenciales emitidas 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, -- mDoc codificado en Base64 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) ); -- Tabla para almacenar claves del emisor (simplificada para la 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, -- formato JWK private_key TEXT NOT NULL, -- formato JWK (cifrado en producción) is_active BOOLEAN DEFAULT TRUE, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, INDEX idx_key_id (key_id), INDEX idx_is_active (is_active) );

Una vez que ambos archivos estén en su lugar, abre tu terminal en la raíz del proyecto y ejecuta:

docker-compose up -d

Este comando iniciará un contenedor de MySQL en segundo plano, listo para que nuestra aplicación lo use.

4.2 Implementando las librerías compartidas#

Antes de construir los endpoints de la API, vamos a crear las librerías compartidas que manejarán la lógica de negocio principal. Este enfoque mantiene nuestras rutas de API limpias y centradas en manejar las solicitudes HTTP, mientras que el trabajo complejo se delega a estos módulos.

4.2.1 La librería de base de datos (src/lib/database.ts)#

Este archivo es la única fuente de verdad para todas las interacciones con la base de datos. Utiliza la librería mysql2 para conectarse a nuestro contenedor de MySQL y proporciona un conjunto de funciones exportadas para crear, leer y actualizar registros en nuestras tablas. Esta capa de abstracción hace que nuestro código sea más modular y fácil de mantener.

Crea el archivo src/lib/database.ts con el siguiente contenido:

// src/lib/database.ts import mysql from "mysql2/promise"; // Configuración de la conexión a la base de datos 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; } // Funciones de Data-Access-Object (DAO) para cada tabla // ... (ej., createChallenge, getChallenge, createAuthorizationCode, etc.)

Nota: Por brevedad, se ha omitido la lista completa de funciones DAO. Puedes encontrar el código completo en el repositorio del proyecto. Este archivo incluye funciones para gestionar desafíos, sesiones de verificación, códigos de autorización, sesiones de emisión y claves de emisor.

4.2.2 La librería de criptografía (src/lib/crypto.ts)#

Este archivo maneja todas las operaciones criptográficas críticas para la seguridad. Utiliza la librería jose para generar pares de claves y firmar JSON Web Tokens (JWTs).

Generación de claves La función generateIssuerKeyPair crea un nuevo par de claves de Curva Elíptica que se utilizará para firmar credenciales. La clave pública se exporta en formato JSON Web Key (JWK) para que pueda publicarse en nuestro 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; // Asignar un ID de clave único // ... (exportación de la clave privada y otra configuración) return { publicKey, privateKey, publicKeyJWK /* ... */ }; }

Creación de credenciales JWT La función createJWTVerifiableCredential es el núcleo del proceso de emisión. Toma las afirmaciones del usuario, el par de claves del emisor y otros metadatos, y los utiliza para crear un JWT-VC firmado.

// 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 = { // El DID del emisor iss: issuerKeyPair.issuerDid, // El DID del sujeto (titular) sub: subjectId, // La hora en que se emitió la credencial (iat) y cuándo expira (exp) iat: now, exp: now + oneYear, // El modelo de datos de Credencial Verificable 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, }, }, }; // Firmar el payload con la clave privada del emisor return await new SignJWT(vcPayload) .setProtectedHeader({ alg: issuerKeyPair.algorithm, kid: issuerKeyPair.keyId, typ: "JWT", }) .sign(issuerKeyPair.privateKey); }

Esta función construye el payload del JWT de acuerdo con el Modelo de Datos de Credenciales Verificables del W3C y lo firma con la clave privada del emisor, produciendo una credencial verificable segura.

4.2 Vista general de la arquitectura de la aplicación Next.js#

Nuestra aplicación Next.js está estructurada para separar responsabilidades entre el frontend y el backend, aunque forman parte del mismo proyecto. Esto se logra aprovechando el App Router tanto para las páginas de la interfaz de usuario como para los endpoints de la API.

  • Frontend (src/app/issue/page.tsx): Un único componente de página de React que define la interfaz de usuario para la ruta /issue. Maneja la entrada del usuario y se comunica con nuestra API de backend.

  • Rutas de API del backend (src/app/api/...):

    • Descubrimiento (.well-known/.../route.ts): Estas rutas exponen endpoints de metadatos públicos que permiten a las billeteras y otros clientes descubrir las capacidades y las claves públicas del emisor.
    • Emisión (issue/.../route.ts): Estos endpoints implementan la lógica central de OpenID4VCI, incluyendo la creación de ofertas de credenciales, la emisión de tokens y la firma de la credencial final.
    • Esquema (schemas/pid/route.ts): Esta ruta sirve el esquema JSON para la credencial, definiendo su estructura.
  • Librería (src/lib/): Este directorio contiene lógica reutilizable compartida en todo el backend.

    • database.ts: Gestiona todas las interacciones con la base de datos, abstrayendo las consultas SQL.
    • crypto.ts: Maneja todas las operaciones criptográficas, como la generación de claves y la firma de JWT.

Esta clara separación hace que la aplicación sea modular y más fácil de mantener.

Nota: La función generateIssuerDid() debe devolver un did:web válido que coincida con tu dominio de emisor. Cuando se despliegue, el archivo .well-known/did.json debe servirse a través de HTTPS en ese dominio para que los verificadores puedan validar las credenciales.

4.3 Construyendo el frontend#

Nuestro frontend es una única página de React que proporciona un formulario simple para que los usuarios soliciten una nueva credencial digital. Sus responsabilidades son:

  • Capturar los datos del usuario (nombre, fecha de nacimiento, etc.).
  • Enviar estos datos a nuestro backend para crear una oferta de credencial.
  • Mostrar el código QR y el PIN resultantes para que el usuario los escanee con su billetera.

La lógica central se maneja en la función handleSubmit, que se activa cuando el usuario envía el formulario.

// src/app/issue/page.tsx const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setLoading(true); setError(null); setCredentialOffer(null); try { // 1. Validar campos requeridos if (!userData.given_name || !userData.family_name || !userData.birth_date) { throw new Error("Por favor, rellena todos los campos requeridos"); } // 2. Solicitar una oferta de credencial al 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 || "Fallo al crear la oferta de credencial", ); } // 3. Establecer la oferta de credencial en el estado para mostrar el código QR const result = await response.json(); setCredentialOffer(result); } catch (err) { const errorMessage = (err as Error).message || "Ocurrió un error desconocido"; setError(errorMessage); } finally { setLoading(false); } };

Esta función realiza tres acciones clave:

  1. Valida los datos del formulario para asegurar que todos los campos requeridos estén completos.
  2. Envía una solicitud POST a nuestro endpoint /api/issue/authorize con los datos del usuario.
  3. Actualiza el estado del componente con la oferta de credencial recibida del backend, lo que hace que la interfaz de usuario muestre el código QR y el código de transacción.

El resto del archivo contiene código estándar de React para renderizar el formulario y mostrar el código QR. Puedes ver el archivo completo en el repositorio del proyecto.

4.4 Configurando el entorno y el descubrimiento#

Antes de construir la API del backend, necesitamos configurar nuestro entorno y establecer los endpoints de descubrimiento. Estos archivos .well-known son cruciales para que las billeteras encuentren nuestro emisor y entiendan cómo interactuar con él.

4.4.1 Crear el archivo de entorno#

Crea un archivo llamado .env.local en la raíz de tu proyecto y añade la siguiente línea. Esta URL debe ser accesible públicamente para que una billetera móvil pueda llegar a ella. Para el desarrollo local, puedes usar un servicio de túnel como ngrok para exponer tu localhost.

NEXT_PUBLIC_BASE_URL=http://localhost:3000

4.4.2 Implementar los endpoints de descubrimiento#

Las billeteras descubren las capacidades de un emisor consultando URLs estándar .well-known. Necesitamos crear tres de estos endpoints.

1. Metadatos del emisor (/.well-known/openid-credential-issuer)

Este es el archivo de descubrimiento principal para OpenID4VCI. Le dice a la billetera todo lo que necesita saber sobre el emisor, incluyendo sus endpoints, los tipos de credenciales que ofrece y los algoritmos criptográficos compatibles.

Crea el archivo 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 = { // El identificador único del emisor. issuer: baseUrl, // La URL del servidor de autorización. Para simplificar, nuestro emisor es su propio servidor de autorización. authorization_servers: [baseUrl], // La URL del emisor de la credencial. credential_issuer: baseUrl, // El endpoint al que la billetera hará POST para recibir la credencial real. credential_endpoint: `${baseUrl}/api/issue/credential`, // El endpoint donde la billetera intercambia un código de autorización por un token de acceso. token_endpoint: `${baseUrl}/api/issue/token`, // El endpoint para el flujo de autorización (no se usa en nuestro flujo preautorizado, pero es una buena práctica incluirlo). authorization_endpoint: `${baseUrl}/api/issue/authorize`, // Indica soporte para el flujo de código preautorizado sin requerir autenticación del cliente. pre_authorized_grant_anonymous_access_supported: true, // Información legible por humanos sobre el emisor. display: [ { name: "Corbado Credentials Issuer", locale: "en-US", }, ], // Una lista de los tipos de credenciales que este emisor puede emitir. credential_configurations_supported: { "eu.europa.ec.eudi.pid.1": { // El formato de la credencial (ej., jwt_vc, mso_mdoc). format: "jwt_vc", // El tipo de documento específico, conforme a los estándares ISO mDoc. doctype: "eu.europa.ec.eudi.pid.1", // El scope de OAuth 2.0 asociado con este tipo de credencial. scope: "eu.europa.ec.eudi.pid.1", // Métodos que la billetera puede usar para probar la posesión de su clave. cryptographic_binding_methods_supported: ["jwk"], // Algoritmos de firma que el emisor soporta para esta credencial. credential_signing_alg_values_supported: ["ES256"], // Tipos de prueba de posesión que la billetera puede usar. proof_types_supported: { jwt: { proof_signing_alg_values_supported: ["ES256", "ES384", "ES512"], }, }, // Propiedades de visualización para la credencial. display: [ { name: "Corbado Credential Issuer", locale: "en-US", logo: { uri: `${baseUrl}/logo.png`, alt_text: "EU Digital Identity", }, background_color: "#003399", text_color: "#FFFFFF", }, ], // Una lista de las claims (atributos) en la credencial. 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" }], }, }, }, }, }, // Métodos de autenticación soportados por el token endpoint. 'none' significa cliente público. token_endpoint_auth_methods_supported: ["none"], // Métodos de desafío de código PKCE soportados. code_challenge_methods_supported: ["S256"], // Tipos de concesión de OAuth 2.0 que el emisor soporta. 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. Configuración de OpenID (/.well-known/openid-configuration)

Este es un documento de descubrimiento estándar de OIDC que proporciona un conjunto más amplio de detalles de configuración.

Crea el archivo 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 = { // El identificador único del emisor. credential_issuer: baseUrl, // El endpoint al que la billetera hará POST para recibir la credencial real. credential_endpoint: `${baseUrl}/api/issue/credential`, // El endpoint para el flujo de autorización. authorization_endpoint: `${baseUrl}/api/issue/authorize`, // El endpoint donde la billetera intercambia un código de autorización por un token de acceso. token_endpoint: `${baseUrl}/api/issue/token`, // Una lista de los tipos de credenciales que este emisor puede emitir. 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"], }, }, }, }, // Tipos de concesión de OAuth 2.0 que el emisor soporta. grant_types_supported: [ "authorization_code", "urn:ietf:params:oauth:grant-type:pre-authorized_code", ], // Indica soporte para el flujo de código preautorizado. pre_authorized_grant_anonymous_access_supported: true, // Métodos de desafío de código PKCE soportados. code_challenge_methods_supported: ["S256"], // Métodos de autenticación soportados por el token endpoint. token_endpoint_auth_methods_supported: ["none"], // Scopes de OAuth 2.0 que el emisor soporta. 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)

Este archivo publica la clave pública del emisor utilizando el método did:web, permitiendo a cualquiera verificar la firma de las credenciales emitidas por él.

Crea el archivo 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 se encontró una clave de emisor activa" }, { status: 404 }, ); } const publicKeyJWK = JSON.parse(issuerKey.public_key); const didId = generateIssuerDid(); const didDocument = { // El contexto define el vocabulario utilizado en el documento. "@context": [ "https://www.w3.org/ns/did/v1", "https://w3id.org/security/suites/jws-2020/v1", ], // La URI del DID, que es el identificador único del emisor. id: didId, // El controlador del DID, que es la entidad que controla el DID. Aquí, es el propio emisor. controller: didId, // Una lista de claves públicas que se pueden usar para verificar las firmas del emisor. verificationMethod: [ { // Un identificador único para la clave, dentro del ámbito del DID. id: `${didId}#${issuerKey.key_id}`, // El tipo de la clave. type: "JsonWebKey2020", // El DID del controlador de la clave. controller: didId, // La clave pública en formato JWK. publicKeyJwk: publicKeyJWK, }, ], // Especifica qué claves se pueden usar para la autenticación (probar el control del DID). authentication: [`${didId}#${issuerKey.key_id}`], // Especifica qué claves se pueden usar para crear credenciales verificables. assertionMethod: [`${didId}#${issuerKey.key_id}`], // Una lista de servicios proporcionados por el sujeto del DID, como el endpoint del emisor. 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", }, }); }

¿Por qué no usar caché? Notarás que estos tres endpoints devuelven cabeceras que evitan agresivamente el almacenamiento en caché (Cache-Control: no-cache, Pragma: no-cache, Expires: 0). Esta es una práctica de seguridad crítica para los documentos de descubrimiento. Las configuraciones del emisor pueden cambiar; por ejemplo, una clave criptográfica podría ser rotada. Si una billetera o un cliente almacenara en caché una versión antigua del archivo did.json o openid-credential-issuer, no podría validar nuevas credenciales o interactuar con endpoints actualizados. Al forzar a los clientes a obtener una copia nueva en cada solicitud, nos aseguramos de que siempre tengan la información más actualizada.

4.4.3 Implementar el endpoint del esquema de la credencial#

La última pieza de nuestra infraestructura pública es el endpoint del esquema de la credencial. Esta ruta sirve un esquema JSON que define formalmente la estructura, los tipos de datos y las restricciones de la credencial PID que estamos emitiendo. Las billeteras y los verificadores pueden usar este esquema para validar el contenido de la credencial.

Crea el archivo src/app/api/schemas/pid/route.ts con el siguiente contenido:

// 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", // Reemplaza con tu dominio real title: "Credencial PID", description: "Un esquema para una Credencial Verificable que representa un Documento de Identificación Personal (PID).", type: "object", properties: { credentialSubject: { type: "object", properties: { given_name: { type: "string" }, family_name: { type: "string" }, birth_date: { type: "string", format: "date" }, // ... otras propiedades del sujeto de la credencial }, required: ["given_name", "family_name", "birth_date"], }, // ... otras propiedades de nivel superior de una Credencial Verificable }, }; return NextResponse.json(schema, { headers: { "Content-Type": "application/schema+json", "Access-Control-Allow-Origin": "*", // Permitir solicitudes de origen cruzado }, }); }

Nota: El esquema JSON para una credencial PID puede ser bastante grande y detallado. Por brevedad, el esquema completo ha sido truncado. Puedes encontrar el archivo completo en el repositorio del proyecto.

4.5 Construyendo los endpoints del backend#

Con el frontend en su lugar, ahora necesitamos la lógica del lado del servidor para manejar el flujo de OpenID4VCI. Empezaremos con el primer endpoint que el frontend llama: /api/issue/authorize.

4.5.1 /api/issue/authorize: Crear la oferta de credencial#

Este endpoint es responsable de tomar los datos del usuario, generar un código seguro de un solo uso y construir una credential_offer que la billetera del usuario pueda entender.

Aquí está la lógica central:

// 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. Validar los datos del usuario 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. Generar un código preautorizado y un PIN const code = uuidv4(); const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutos const txCode = Math.floor(1000 + Math.random() * 9000).toString(); // PIN de 4 dígitos // 3. Almacenar el código y los datos del usuario await createAuthorizationCode(uuidv4(), code, expiresAt); // Nota: Esto utiliza un almacén en memoria solo para fines de demostración. // En producción, persiste los datos de forma segura en una base de datos con una expiración adecuada. 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. Crear el objeto de oferta de credencial const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3000"; const credentialOffer = { // El identificador del emisor, que es su URL base. credential_issuer: baseUrl, // Un array de tipos de credenciales que el emisor está ofreciendo. credential_configuration_ids: ["eu.europa.ec.eudi.pid.1"], // Especifica los tipos de concesión que la billetera puede usar. grants: { // Estamos usando el flujo de código preautorizado. "urn:ietf:params:oauth:grant-type:pre-authorized_code": { // El código de un solo uso que la billetera intercambiará por un token. "pre-authorized_code": code, // Indica que el usuario debe introducir un PIN (tx_code) para canjear el código. user_pin_required: true, }, }, }; // 5. Crear la URI completa de la oferta de credencial (un deep link para billeteras) const credentialOfferUri = `openid-credential-offer://?credential_offer=${encodeURIComponent( JSON.stringify(credentialOffer), )}`; // La respuesta final al frontend. return NextResponse.json({ // El deep link para el código QR. credential_offer_uri: credentialOfferUri, // El código preautorizado en crudo, para mostrarlo o para entrada manual. pre_authorized_code: code, // El PIN de 4 dígitos que el usuario debe introducir en su billetera. tx_code: txCode, }); } catch (error) { console.error("Error de autorización:", error); return NextResponse.json({ error: "server_error" }, { status: 500 }); } }

Pasos clave en este endpoint:

  1. Validar datos: Primero se asegura de que los datos de usuario requeridos estén presentes.
  2. Generar códigos: Crea un pre-authorized_code único (un UUID) y un tx_code de 4 dígitos (PIN) para una capa extra de seguridad.
  3. Persistir datos: El pre-authorized_code se almacena en la base de datos con un tiempo de expiración corto. Los datos del usuario y el PIN se almacenan en memoria, vinculados al código.
  4. Construir oferta: Construye el objeto credential_offer según la especificación de OpenID4VCI. Este objeto le dice a la billetera dónde está el emisor, qué credenciales ofrece y el código necesario para obtenerlas.
  5. Devolver URI: Finalmente, crea una URI de enlace profundo (openid-credential-offer://...) y la devuelve al frontend, junto con el tx_code para que el usuario lo vea.

4.5.2 /api/issue/token: Intercambiar el código por un token#

Una vez que el usuario escanea el código QR e introduce su PIN, la billetera realiza una solicitud POST a este endpoint. Su trabajo es validar el pre-authorized_code y el user_pin (PIN), y si son válidos, emitir un token de acceso de corta duración.

Crea el archivo src/app/api/issue/token/route.ts con el siguiente contenido:

// 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. Validar el tipo de concesión if (grant_type !== "urn:ietf:params:oauth:grant-type:pre-authorized_code") { return NextResponse.json( { error: "unsupported_grant_type" }, { status: 400 }, ); } // 2. Validar el código preautorizado const authCode = await getAuthorizationCode(code); if (!authCode) { return NextResponse.json( { error: "invalid_grant", error_description: "Código inválido o expirado", }, { status: 400 }, ); } // 3. Validar el PIN (tx_code) const expectedTxCode = (global as any).txCodeStore?.get(code); if (expectedTxCode !== user_pin) { return NextResponse.json( { error: "invalid_grant", error_description: "PIN inválido" }, { status: 400 }, ); } // 4. Generar token de acceso y c_nonce const accessToken = uuidv4(); const cNonce = uuidv4(); const cNonceExpiresAt = new Date(Date.now() + 5 * 60 * 1000); // 5 minutos // 5. Crear una nueva sesión de emisión const userData = (global as any).userDataStore?.get(code); await createIssuanceSession( uuidv4(), authCode.id, accessToken, cNonce, cNonceExpiresAt, userData, ); // 6. Marcar el código como usado y limpiar los datos temporales await markAuthorizationCodeAsUsed(code); (global as any).txCodeStore?.delete(code); (global as any).userDataStore?.delete(code); // 7. Devolver la respuesta del token de acceso return NextResponse.json({ access_token: accessToken, token_type: "Bearer", expires_in: 3600, // 1 hora c_nonce: cNonce, c_nonce_expires_in: 300, // 5 minutos }); } catch (error) { console.error("Error en el endpoint del token:", error); return NextResponse.json({ error: "server_error" }, { status: 500 }); } }

Pasos clave en este endpoint:

  1. Validar tipo de concesión: Se asegura de que la billetera esté utilizando el tipo de concesión pre-authorized_code correcto.
  2. Validar código: Comprueba que el pre-authorized_code exista en la base de datos, no haya expirado y no se haya utilizado antes.
  3. Validar PIN: Compara el user_pin de la billetera con el tx_code que almacenamos anteriormente para asegurar que el usuario autorizó la transacción.
  4. Generar tokens: Crea un access_token seguro y un c_nonce (nonce de credencial), que es un valor de un solo uso para prevenir ataques de repetición en el endpoint de la credencial.
  5. Crear sesión: Crea un nuevo registro issuance_sessions en la base de datos, vinculando el token de acceso a los datos del usuario.
  6. Marcar código como usado: Para evitar que la misma oferta se use dos veces, marca el pre-authorized_code como usado.
  7. Devolver token: Devuelve el access_token y el c_nonce a la billetera.

4.5.3 /api/issue/credential: Emitir la credencial firmada#

Este es el endpoint final y más importante. La billetera utiliza el token de acceso que recibió del endpoint /token para realizar una solicitud POST autenticada a esta ruta. El trabajo de este endpoint es realizar la validación final, crear la credencial firmada criptográficamente y devolverla a la billetera.

Crea el archivo src/app/api/issue/credential/route.ts con el siguiente contenido:

// 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. Validar el token Bearer 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. Obtener los datos del usuario de la sesión const userData = session.user_data; if (!userData) { return NextResponse.json({ error: "missing_user_data" }, { status: 400 }); } // 3. Obtener la clave de emisor activa const issuerKey = await getActiveIssuerKey(); if (!issuerKey) { // En una aplicación real, tendrías un sistema de gestión de claves más robusto. // Para esta demostración, podemos generar una clave sobre la marcha si no existe. // Esta parte se omite por brevedad, pero está en el repositorio. return NextResponse.json( { error: "server_error", error_description: "Fallo al obtener la clave del emisor", }, { status: 500 }, ); } // 4. Crear el 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. Almacenar la credencial emitida en la base de datos await createIssuedCredential(/* ... detalles de la credencial ... */); await updateIssuanceSession(session.id, "credential_issued"); // 6. Devolver la credencial firmada return NextResponse.json({ format: "jwt_vc", credential: credentialData, c_nonce: uuidv4(), // Un nuevo nonce para solicitudes posteriores c_nonce_expires_in: 300, }); } catch (error) { console.error("Error en el endpoint de la credencial:", error); return NextResponse.json({ error: "server_error" }, { status: 500 }); } }

Pasos clave en este endpoint:

  1. Validar token: Comprueba si hay un token Bearer válido en la cabecera Authorization y lo utiliza para buscar la sesión de emisión activa.
  2. Recuperar datos de usuario: Recupera los datos de las afirmaciones del usuario, que se almacenaron en la sesión cuando se creó el token.
  3. Cargar clave del emisor: Carga la clave de firma activa del emisor desde la base de datos. En un escenario real, esto sería gestionado por un sistema de gestión de claves seguro.
  4. Crear credencial: Llama a nuestra función de ayuda createJWTVerifiableCredential de src/lib/crypto.ts para construir y firmar el JWT-VC.
  5. Registrar emisión: Guarda un registro de la credencial emitida en la base de datos para fines de auditoría y revocación.
  6. Devolver credencial: Devuelve la credencial firmada a la billetera en una respuesta JSON. La billetera es entonces responsable de almacenarla de forma segura.

5. Ejecutando el emisor y próximos pasos#

Ahora tienes una implementación completa de extremo a extremo de un emisor de credenciales digitales. A continuación, te explicamos cómo ejecutarlo localmente y qué necesitas considerar para llevarlo de una prueba de concepto a una aplicación lista para producción.

5.1 Cómo ejecutar el ejemplo#

  1. Clona el repositorio:

    git clone https://github.com/corbado/digital-credentials-example.git cd digital-credentials-example
  2. Instala las dependencias:

    npm install
  3. Inicia la base de datos: Asegúrate de que Docker esté en ejecución, luego inicia el contenedor de MySQL:

    docker-compose up -d
  4. Configura el entorno y ejecuta el túnel: Este es el paso más crítico para las pruebas locales. Dado que tu billetera móvil necesita conectarse a tu máquina de desarrollo a través de Internet, debes exponer tu servidor local con una URL HTTPS pública. Usaremos ngrok para esto.

    a. Inicia ngrok:

    ngrok http 3000

    b. Copia la URL HTTPS de la salida de ngrok (por ejemplo, https://random-string.ngrok.io). c. Crea un archivo .env.local y establece la URL:

    NEXT_PUBLIC_BASE_URL=https://<tu-url-de-ngrok>
  5. Ejecuta la aplicación:

    npm run dev

    Abre tu navegador en http://localhost:3000/issue. Ahora puedes rellenar el formulario, y el código QR generado apuntará correctamente a tu URL pública de ngrok, permitiendo que tu billetera móvil se conecte y reciba la credencial.

5.2 La importancia de HTTPS y ngrok#

Los protocolos de credenciales digitales se construyen con la seguridad como máxima prioridad. Por esta razón, las billeteras casi siempre se negarán a conectarse a un emisor a través de una conexión insegura (http://). Todo el proceso se basa en una conexión HTTPS segura, que es habilitada por un certificado SSL.

Un servicio de túnel como ngrok resuelve ambos problemas creando una URL HTTPS segura y pública (con un certificado SSL válido) que reenvía todo el tráfico a tu servidor de desarrollo local. Las billeteras requieren HTTPS y se negarán a conectarse a endpoints inseguros (http://). Esta es una herramienta esencial para probar cualquier servicio web que necesite interactuar con dispositivos móviles o webhooks externos.

5.3 Qué está fuera del alcance de este tutorial#

Este ejemplo se centra intencionadamente en el flujo de emisión principal para que sea fácil de entender. Los siguientes temas se consideran fuera de alcance:

  • Seguridad lista para producción: El emisor es para fines educativos. Un sistema de producción requeriría un Sistema de Gestión de Claves (KMS) seguro en lugar de almacenar claves en una base de datos, un manejo robusto de errores, limitación de velocidad y un registro de auditoría completo.
  • Revocación de credenciales: Esta guía no implementa un mecanismo para revocar las credenciales emitidas. Aunque el esquema incluye una bandera revoked para uso futuro, no se proporciona lógica de revocación aquí.
  • Flujo de código de autorización: Nos centramos exclusivamente en el flujo de pre-authorized_code. Una implementación completa del flujo de authorization_code requeriría una pantalla de consentimiento del usuario y una lógica de OAuth 2.0 más compleja.
  • Gestión de usuarios: La guía no incluye ninguna autenticación o gestión de usuarios para el propio emisor. Se asume que el usuario ya está autenticado y autorizado para recibir una credencial.

6. Conclusión#

¡Eso es todo! Con unas pocas páginas de código, ahora tenemos un emisor de credenciales digitales completo y de extremo a extremo que:

  1. Proporciona un frontend fácil de usar para solicitar credenciales.
  2. Implementa el flujo completo de OpenID4VCI pre-authorized_code.
  3. Expone todos los endpoints de descubrimiento necesarios para la interoperabilidad de las billeteras.
  4. Genera y firma una credencial verificable JWT segura y conforme a los estándares.

Aunque esta guía proporciona una base sólida, un emisor listo para producción requeriría características adicionales como una gestión robusta de claves, almacenamiento persistente en lugar de almacenes en memoria, revocación de credenciales y un endurecimiento de la seguridad completo. La compatibilidad de las billeteras también varía; se recomienda Sphereon Wallet para las pruebas, pero es posible que otras billeteras no admitan el flujo preautorizado tal como se implementa aquí. Sin embargo, los componentes básicos y el flujo de interacción seguirían siendo los mismos. Siguiendo estos patrones, puedes construir un emisor seguro e interoperable para cualquier tipo de credencial digital.

7. Recursos#

Aquí tienes algunos de los recursos, especificaciones y herramientas clave utilizados o referenciados en este tutorial:

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

Start Free Trial

Share this article


LinkedInTwitterFacebook

Table of Contents