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

Como construir um Emissor de Credenciais Digitais (Guia para Desenvolvedores)

Aprenda a construir um emissor de Credenciais Verificáveis W3C usando o protocolo OpenID4VCI. Este guia passo a passo mostra como criar uma aplicação Next.js que emite credenciais assinadas criptograficamente e compatíveis com carteiras digitais.

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. Introdução#

As Credenciais Digitais são uma forma poderosa de comprovar identidade e alegações de maneira segura e que preserva a privacidade. Mas como os usuários obtêm essas credenciais? É aqui que o papel do Emissor se torna crucial. Um Emissor é uma entidade confiável — como uma agência governamental, uma universidade ou um banco — responsável por criar e distribuir credenciais assinadas digitalmente para os usuários.

Este guia oferece um tutorial completo e passo a passo para construir um Emissor de Credenciais Digitais. Vamos nos concentrar no protocolo OpenID for Verifiable Credential Issuance (OpenID4VCI), um padrão moderno que define como os usuários podem obter credenciais de um Emissor e armazená-las com segurança em suas wallets digitais.

O resultado final será uma aplicação Next.js funcional que pode:

  1. Aceitar dados do usuário por meio de um formulário web simples.
  2. Gerar uma oferta de credencial segura e de uso único.
  3. Exibir a oferta como um código QR para o usuário escanear com sua wallet móvel.
  4. Emitir uma credencial assinada criptograficamente que o usuário pode armazenar e apresentar para verificação.

1.1 Entendendo a Terminologia: Credenciais Digitais vs. Credenciais Verificáveis#

Antes de prosseguirmos, é importante esclarecer a distinção entre dois conceitos relacionados, mas diferentes:

  • Credenciais Digitais (Termo Geral): Esta é uma categoria ampla que engloba qualquer forma digital de credenciais, certificados ou atestados. Isso pode incluir certificados digitais simples, selos digitais básicos ou qualquer credencial armazenada eletronicamente que pode ou não ter recursos de segurança criptográfica.

  • Credenciais Verificáveis (VCs - Padrão W3C): Este é um tipo específico de credencial digital que segue o padrão W3C Verifiable Credentials Data Model. As Credenciais Verificáveis são credenciais assinadas criptograficamente, à prova de adulteração e que respeitam a privacidade, podendo ser verificadas de forma independente. Elas incluem requisitos técnicos específicos como:

    • Assinaturas criptográficas para autenticidade e integridade
    • Modelo de dados e formatos padronizados
    • Mecanismos de apresentação que preservam a privacidade
    • Protocolos de verificação interoperáveis

Neste guia, estamos construindo especificamente um emissor de Credenciais Verificáveis que segue o padrão W3C, e não apenas um sistema de credenciais digitais qualquer. O protocolo OpenID4VCI que estamos usando foi projetado especificamente para a emissão de Credenciais Verificáveis, e o formato JWT-VC que implementaremos é um formato compatível com W3C para Credenciais Verificáveis.

1.2 Como Funciona#

A mágica por trás das credenciais digitais reside em um modelo simples, mas poderoso, de "triângulo de confiança", envolvendo três atores principais:

  • Emissor: Uma autoridade confiável (ex: uma agência governamental, universidade ou banco) que assina criptograficamente e emite uma credencial para um usuário. Este é o papel que estamos construindo neste guia.
  • Portador (Holder): O usuário, que recebe a credencial e a armazena com segurança em uma wallet digital pessoal em seu dispositivo.
  • Verificador: Uma aplicação ou serviço que precisa verificar a credencial do usuário.

O fluxo de emissão é o primeiro passo neste ecossistema. O Emissor valida as informações do usuário e fornece a ele uma credencial. Uma vez que o Portador tenha essa credencial em sua wallet, ele pode apresentá-la a um Verificador para provar sua identidade ou alegações, completando o triângulo.

Aqui está uma rápida olhada na aplicação final em ação:

Passo 1: Entrada de Dados do Usuário O usuário preenche um formulário com suas informações pessoais para solicitar uma nova credencial.

Passo 2: Geração da Oferta de Credencial A aplicação gera uma oferta de credencial segura, exibida como um código QR e um código pré-autorizado.

Passo 3: Interação com a Wallet O usuário escaneia o código QR com uma wallet compatível (ex: Sphereon Wallet) e insere um PIN para autorizar a emissão.

Passo 4: Credencial Emitida A wallet recebe e armazena a nova credencial digital emitida, pronta para uso futuro.

2. Pré-requisitos para Construir um Emissor#

Antes de mergulharmos no código, vamos cobrir o conhecimento fundamental e as ferramentas que você precisará. Este guia assume que você tem uma familiaridade básica com conceitos de desenvolvimento web, mas os seguintes pré-requisitos são essenciais para construir um emissor de credenciais.

2.1 Escolhas de Protocolo#

Nosso Emissor é construído sobre um conjunto de padrões abertos que garantem a interoperabilidade entre wallets e serviços de emissão. Para este tutorial, vamos focar nos seguintes:

Padrão / ProtocoloDescrição
OpenID4VCIOpenID for Verifiable Credential Issuance. Este é o protocolo central que usaremos. Ele define um fluxo padrão de como um usuário (através de sua wallet) pode solicitar e receber uma credencial de um Emissor.
JWT-VCCredenciais Verificáveis baseadas em JWT. O formato da credencial que emitiremos. É um padrão W3C que codifica credenciais verificáveis como JSON Web Tokens (JWTs), tornando-as compactas e amigáveis para a web.
ISO mDocISO/IEC 18013-5. O padrão internacional para Carteiras de Motorista móveis (mDLs). Embora emitamos um JWT-VC, as claims dentro dele são estruturadas para serem compatíveis com o modelo de dados mDoc (ex: eu.europa.ec.eudi.pid.1).
OAuth 2.0O framework de autorização subjacente usado pelo OpenID4VCI. Implementaremos um fluxo pre-authorized_code, que é um tipo de concessão específico projetado para emissão de credenciais segura e amigável ao usuário.

2.1.1 Fluxos de Autorização: Pré-Autorizado vs. Código de Autorização#

O OpenID4VCI suporta dois fluxos de autorização primários para a emissão de credenciais:

  1. Fluxo de Código Pré-Autorizado: Neste fluxo, o Emissor gera um código de uso único e de curta duração (pre-authorized_code) que fica imediatamente disponível para o usuário. A wallet do usuário pode então trocar esse código diretamente por uma credencial. Este fluxo é ideal para cenários onde o usuário já está autenticado e presente no site do Emissor, pois proporciona uma experiência de emissão instantânea e contínua, sem redirecionamentos.

  2. Fluxo de Código de Autorização: Este é o fluxo padrão do OAuth 2.0, onde o usuário é redirecionado para um servidor de autorização para dar consentimento. Após a aprovação, o servidor envia um authorization_code de volta para uma redirect_uri registrada. Este fluxo é mais adequado para aplicações de terceiros que iniciam o processo de emissão em nome do usuário.

Para este tutorial, usaremos o fluxo pre-authorized_code. Escolhemos essa abordagem porque é mais simples и oferece uma experiência de usuário mais direta para o nosso caso de uso específico: um usuário solicitando diretamente uma credencial do próprio site do Emissor. Isso elimina a necessidade de redirecionamentos complexos e registro de cliente, tornando a lógica central de emissão mais fácil de entender e implementar.

Essa combinação de padrões nos permite construir um emissor compatível com uma ampla gama de wallets digitais e garante um processo seguro e padronizado para o usuário.

2.2 Escolhas da Stack de Tecnologia#

Para construir nosso emissor, usaremos a mesma stack de tecnologia robusta e moderna que usamos para o verificador, garantindo uma experiência de desenvolvimento consistente e de alta qualidade.

2.2.1 Linguagem: TypeScript#

Usaremos TypeScript tanto para o nosso código de frontend quanto de backend. Sua tipagem estática é inestimável em uma aplicação crítica de segurança como um emissor, pois ajuda a prevenir erros comuns e melhora a qualidade geral e a manutenibilidade do código.

2.2.2 Framework: Next.js#

Next.js é nosso framework de escolha porque proporciona uma experiência integrada e fluida para a construção de aplicações full-stack.

  • Para o Frontend: Usaremos Next.js com React para construir a interface do usuário onde os usuários podem inserir seus dados para solicitar uma credencial.
  • Para o Backend: Aproveitaremos as API Routes do Next.js para criar os endpoints do lado do servidor que lidam com o fluxo OpenID4VCI, desde a geração de ofertas de credenciais até a emissão da credencial final assinada.

2.2.3 Bibliotecas Principais#

Nossa implementação dependerá de algumas bibliotecas principais para lidar com tarefas específicas:

  • next, react e react-dom: As bibliotecas centrais para nossa aplicação Next.js.
  • mysql2: Um cliente MySQL para Node.js, usado para armazenar códigos de autorização e dados de sessão.
  • uuid: Uma biblioteca para gerar identificadores únicos, que usaremos para criar valores de pre-authorized_code.
  • jose: Uma biblioteca robusta para lidar com JSON Web Signatures (JWS), que usaremos para assinar criptograficamente as credenciais que emitimos.

2.3 Obtenha uma Wallet de Teste#

Para testar seu emissor, você precisará de uma wallet móvel que suporte o protocolo OpenID4VCI. Para este tutorial, recomendamos a Sphereon Wallet, que está disponível tanto para Android quanto para iOS.

Como Instalar a Sphereon Wallet:

  1. Baixe a wallet da Google Play Store ou da Apple App Store.
  2. Instale o aplicativo em seu dispositivo móvel.
  3. Uma vez instalada, a wallet está pronta para receber ofertas de credenciais escaneando um código QR.

2.4 Conhecimento de Criptografia#

A emissão de uma credencial é uma operação crítica de segurança que se baseia em conceitos criptográficos fundamentais para garantir confiança e autenticidade.

2.4.1 Assinaturas Digitais#

No seu cerne, uma Credencial Verificável é um conjunto de alegações que foi assinado digitalmente pelo Emissor. Essa assinatura oferece duas garantias:

  • Autenticidade: Prova que a credencial foi criada por um Emissor legítimo.
  • Integridade: Prova que a credencial não foi adulterada desde que foi emitida.

2.4.2 Criptografia de Chave Pública/Privada#

As assinaturas digitais são criadas usando criptografia de chave pública/privada. Veja como funciona:

  1. O Emissor tem um par de chaves: uma chave privada, que é mantida em segredo e segura, e uma chave pública correspondente, que é disponibilizada publicamente.
  2. Assinatura: Quando o Emissor cria uma credencial, ele usa sua chave privada para gerar uma assinatura digital única para os dados da credencial.
  3. Verificação: Um Verificador pode, posteriormente, usar a chave pública do Emissor para verificar a assinatura. Se a verificação for bem-sucedida, o Verificador sabe que a credencial é autêntica e não foi alterada.

Em nossa implementação, geraremos um par de chaves de Curva Elíptica (EC) e usaremos o algoritmo ES256 para assinar o JWT-VC. A chave pública é incorporada no DID do Emissor (did:web), permitindo que qualquer Verificador a descubra e valide a assinatura da credencial.
Nota: A claim aud (audience) é omitida intencionalmente em nossos JWTs, pois a credencial é projetada para ser de propósito geral e não vinculada a uma wallet específica.
Se você quiser restringir o uso a um público específico, inclua uma claim aud e a configure adequadamente.

3. Visão Geral da Arquitetura#

Nossa aplicação de Emissor é construída como um projeto Next.js full-stack, com uma separação clara entre a lógica do frontend e do backend. Essa arquitetura nos permite criar uma experiência de usuário fluida, enquanto lidamos com todas as operações críticas de segurança no servidor.
Importante: As tabelas verification_sessions e verified_credentials incluídas no SQL não são necessárias para este emissor, mas estão incluídas para fins de completude.

  • Frontend (src/app/issue/page.tsx): Uma única página React que permite aos usuários inserir seus dados para solicitar uma credencial. Ela faz chamadas de API para o nosso backend para iniciar o processo de emissão.
  • Rotas de API do Backend (src/app/api/issue/...): Um conjunto de endpoints do lado do servidor que implementam o protocolo OpenID4VCI.
    • /.well-known/openid-credential-issuer: Um endpoint de metadados público. Esta é a primeira URL que uma wallet verificará para descobrir as capacidades do emissor, incluindo seu servidor de autorização, endpoint de token, endpoint de credencial e os tipos de credenciais que oferece.
    • /.well-known/openid-configuration: Um endpoint de descoberta padrão do OpenID Connect. Embora intimamente relacionado ao anterior, este endpoint serve configurações mais amplas relacionadas ao OIDC e é frequentemente necessário для interoperabilidade com clientes OpenID padrão.
    • /.well-known/did.json: O Documento DID para nosso emissor. Ao usar o método did:web, este arquivo é usado para publicar as chaves públicas do emissor, que os verificadores podem usar para validar as assinaturas das credenciais que ele emite.
    • authorize/route.ts: Cria um pre-authorized_code e uma oferta de credencial.
    • token/route.ts: Troca o pre-authorized_code por um access token.
    • credential/route.ts: Emite o JWT-VC final, assinado criptograficamente.
    • schemas/pid/route.ts: Expõe o esquema JSON para a credencial PID. Isso permite que qualquer consumidor da credencial entenda sua estrutura e tipos de dados.
  • Biblioteca (src/lib/):
    • database.ts: Gerencia todas as interações com o banco de dados, como o armazenamento de códigos de autorização e chaves do emissor.
    • crypto.ts: Lida com todas as operações criptográficas, incluindo geração de chaves e assinatura de JWT.

Aqui está um diagrama ilustrando o fluxo de emissão:

4. Construindo o Emissor#

Agora que temos uma compreensão sólida dos padrões, protocolos e arquitetura, podemos começar a construir nosso emissor.

Acompanhe ou Use o Código Final

Vamos agora passar pela configuração e implementação do código passo a passo. Se você preferir ir direto para o produto final, pode clonar o projeto completo do nosso repositório no GitHub e executá-lo localmente.

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

4.1 Configurando o Projeto#

Primeiro, vamos inicializar um novo projeto Next.js, instalar as dependências necessárias e iniciar nosso banco de dados.

4.1.1 Inicializando a App Next.js#

Abra seu terminal, navegue até o diretório onde você deseja criar seu projeto e execute o seguinte comando. Estamos usando o App Router, TypeScript e Tailwind CSS para este projeto.

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

Este comando cria o esqueleto de uma nova aplicação Next.js no seu diretório atual.

4.1.2 Instalando Dependências#

Em seguida, precisamos instalar as bibliotecas que lidarão com JWTs, conexões de banco de dados e geração de UUID.

npm install jose mysql2 uuid @types/uuid

Este comando instala:

  • jose: Para assinar e verificar JSON Web Tokens (JWTs).
  • mysql2: O cliente MySQL para nosso banco de dados.
  • uuid: Para gerar strings de desafio únicas.
  • @types/uuid: Tipos TypeScript para a biblioteca uuid.

4.1.3 Iniciando o Banco de Dados#

Nosso backend requer um banco de dados MySQL para armazenar códigos de autorização, sessões de emissão e chaves do emissor. Incluímos um arquivo docker-compose.yml para facilitar isso.

Se você clonou o repositório, pode simplesmente executar docker-compose up -d. Se estiver construindo do zero, crie um arquivo chamado docker-compose.yml com o seguinte conteúdo:

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 configuração do Docker Compose também requer um script de inicialização SQL. Crie um diretório chamado sql e, dentro dele, um arquivo chamado init.sql com o seguinte conteúdo para configurar as tabelas necessárias tanto para o verificador quanto para o emissor:

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

Uma vez que ambos os arquivos estejam no lugar, abra seu terminal na raiz do projeto e execute:

docker-compose up -d

Este comando iniciará um contêiner MySQL em segundo plano, pronto para nossa aplicação usar.

4.2 Implementando as Bibliotecas Compartilhadas#

Antes de construirmos os endpoints da API, vamos criar as bibliotecas compartilhadas que lidarão com a lógica de negócio principal. Essa abordagem mantém nossas rotas de API limpas e focadas em lidar com requisições HTTP, enquanto o trabalho complexo é delegado a esses módulos.

4.2.1 A Biblioteca de Banco de Dados (src/lib/database.ts)#

Este arquivo é a única fonte da verdade para todas as interações com o banco de dados. Ele usa a biblioteca mysql2 para se conectar ao nosso contêiner MySQL e fornece um conjunto de funções exportadas para criar, ler e atualizar registros em nossas tabelas. Essa camada de abstração torna nosso código mais modular e fácil de manter.

Crie o arquivo src/lib/database.ts com o seguinte conteúdo:

// 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: Por brevidade, a lista completa de funções DAO foi omitida. Você pode encontrar o código completo no repositório do projeto. Este arquivo inclui funções para gerenciar desafios, sessões de verificação, códigos de autorização, sessões de emissão e chaves do emissor.

4.2.2 A Biblioteca de Criptografia (src/lib/crypto.ts)#

Este arquivo lida com todas as operações criptográficas críticas de segurança. Ele usa a biblioteca jose para gerar pares de chaves e assinar JSON Web Tokens (JWTs).

Geração de Chave A função generateIssuerKeyPair cria um novo par de chaves de Curva Elíptica que será usado para assinar credenciais. A chave pública é exportada no formato JSON Web Key (JWK) para que possa ser publicada em nosso 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 /* ... */ }; }

Criação de Credencial JWT A função createJWTVerifiableCredential é o núcleo do processo de emissão. Ela pega as alegações do usuário, o par de chaves do emissor e outros metadados, e os usa para criar um JWT-VC assinado.

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

Esta função constrói o payload do JWT de acordo com o W3C Verifiable Credentials Data Model e o assina com a chave privada do emissor, produzindo uma credencial verificável segura.

4.2 Visão Geral da Arquitetura da App Next.js#

Nossa aplicação Next.js é estruturada para separar as responsabilidades entre o frontend e o backend, embora façam parte do mesmo projeto. Isso é alcançado aproveitando o App Router tanto para páginas de UI quanto para endpoints de API.

  • Frontend (src/app/issue/page.tsx): Um único componente de página React que define a UI para a rota /issue. Ele lida com a entrada do usuário e se comunica com nossa API de backend.

  • Rotas de API do Backend (src/app/api/...):

    • Descoberta (.well-known/.../route.ts): Essas rotas expõem endpoints de metadados públicos que permitem que wallets e outros clientes descubram as capacidades e chaves públicas do emissor.
    • Emissão (issue/.../route.ts): Esses endpoints implementam a lógica principal do OpenID4VCI, incluindo a criação de ofertas de credenciais, emissão de tokens e assinatura da credencial final.
    • Esquema (schemas/pid/route.ts): Esta rota serve o esquema JSON para a credencial, definindo sua estrutura.
  • Biblioteca (src/lib/): Este diretório contém lógica reutilizável compartilhada em todo o backend.

    • database.ts: Gerencia todas as interações com o banco de dados, abstraindo as consultas SQL.
    • crypto.ts: Lida com todas as operações criptográficas, como geração de chaves e assinatura de JWT.

Essa separação clara torna a aplicação modular e mais fácil de manter.

Nota: A função generateIssuerDid() deve retornar um did:web válido que corresponda ao seu domínio de emissor.
Quando implantado, o .well-known/did.json deve ser servido sobre HTTPS nesse domínio para que os verificadores possam validar as credenciais.

4.3 Construindo o Frontend#

Nosso frontend é uma única página React que fornece um formulário simples para os usuários solicitarem uma nova credencial digital. Suas responsabilidades são:

  • Capturar dados do usuário (nome, data de nascimento, etc.).
  • Enviar esses dados ao nosso backend para criar uma oferta de credencial.
  • Exibir o código QR e o PIN resultantes para o usuário escanear com sua wallet.

A lógica principal é tratada na função handleSubmit, que é acionada quando o usuário envia o formulário.

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

Esta função executa três ações principais:

  1. Valida os dados do formulário para garantir que todos os campos obrigatórios sejam preenchidos.
  2. Envia uma requisição POST para nosso endpoint /api/issue/authorize com os dados do usuário.
  3. Atualiza o estado do componente com a oferta de credencial recebida do backend, o que aciona a UI para exibir o código QR e o código de transação.

O resto do arquivo contém código React padrão para renderizar o formulário e a exibição do código QR. Você pode ver o arquivo completo no repositório do projeto.

4.4 Configurando o Ambiente e a Descoberta#

Antes de construirmos a API do backend, precisamos configurar nosso ambiente e os endpoints de descoberta. Esses arquivos .well-known são cruciais para que as wallets encontrem nosso emissor e entendam como interagir com ele.

4.4.1 Crie o Arquivo de Ambiente#

Crie um arquivo chamado .env.local na raiz do seu projeto e adicione a seguinte linha. Esta URL deve ser publicamente acessível para que uma wallet móvel possa alcançá-la. Para desenvolvimento local, você pode usar um serviço de tunelamento como o ngrok para expor seu localhost.

NEXT_PUBLIC_BASE_URL=http://localhost:3000

4.4.2 Implemente os Endpoints de Descoberta#

As wallets descobrem as capacidades de um emissor consultando URLs .well-known padrão. Precisamos criar três desses endpoints.

1. Metadados do Emissor (/.well-known/openid-credential-issuer)

Este é o arquivo de descoberta principal para o OpenID4VCI. Ele informa à wallet tudo o que ela precisa saber sobre o emissor, incluindo seus endpoints, os tipos de credenciais que oferece e os algoritmos criptográficos suportados.

Crie o arquivo 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 = { // O identificador único do emissor. issuer: baseUrl, // A URL do servidor de autorização. Para simplificar, nosso emissor é seu próprio servidor de autorização. authorization_servers: [baseUrl], // A URL do emissor da credencial. credential_issuer: baseUrl, // O endpoint onde a wallet fará POST para receber a credencial real. credential_endpoint: `${baseUrl}/api/issue/credential`, // O endpoint onde a wallet troca um código de autorização por um token de acesso. token_endpoint: `${baseUrl}/api/issue/token`, // O endpoint para o fluxo de autorização (não usado em nosso fluxo pré-autorizado, mas é uma boa prática incluir). authorization_endpoint: `${baseUrl}/api/issue/authorize`, // Indica suporte ao fluxo de código pré-autorizado sem exigir autenticação do cliente. pre_authorized_grant_anonymous_access_supported: true, // Informações legíveis por humanos sobre o emissor. display: [ { name: "Corbado Credentials Issuer", locale: "en-US", }, ], // Uma lista dos tipos de credenciais que este emissor pode emitir. credential_configurations_supported: { "eu.europa.ec.eudi.pid.1": { // O formato da credencial (ex: jwt_vc, mso_mdoc). format: "jwt_vc", // O tipo de documento específico, em conformidade com os padrões ISO mDoc. doctype: "eu.europa.ec.eudi.pid.1", // O escopo OAuth 2.0 associado a este tipo de credencial. scope: "eu.europa.ec.eudi.pid.1", // Métodos que a wallet pode usar para provar a posse de sua chave. cryptographic_binding_methods_supported: ["jwk"], // Algoritmos de assinatura que o emissor suporta para esta credencial. credential_signing_alg_values_supported: ["ES256"], // Tipos de prova de posse que a wallet pode usar. proof_types_supported: { jwt: { proof_signing_alg_values_supported: ["ES256", "ES384", "ES512"], }, }, // Propriedades de exibição para a 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", }, ], // Uma lista das alegações (atributos) na 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 autenticação suportados pelo endpoint de token. 'none' significa cliente público. token_endpoint_auth_methods_supported: ["none"], // Métodos de desafio de código PKCE suportados. code_challenge_methods_supported: ["S256"], // Tipos de concessão OAuth 2.0 que o emissor suporta. 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. Configuração OpenID (/.well-known/openid-configuration)

Este é um documento de descoberta OIDC padrão que fornece um conjunto mais amplo de detalhes de configuração.

Crie o arquivo 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 = { // O identificador único do emissor. credential_issuer: baseUrl, // O endpoint onde a wallet fará POST para receber a credencial real. credential_endpoint: `${baseUrl}/api/issue/credential`, // O endpoint para o fluxo de autorização. authorization_endpoint: `${baseUrl}/api/issue/authorize`, // O endpoint onde a wallet troca um código de autorização por um token de acesso. token_endpoint: `${baseUrl}/api/issue/token`, // Uma lista dos tipos de credenciais que este emissor pode 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 concessão OAuth 2.0 que o emissor suporta. grant_types_supported: [ "authorization_code", "urn:ietf:params:oauth:grant-type:pre-authorized_code", ], // Indica suporte ao fluxo de código pré-autorizado. pre_authorized_grant_anonymous_access_supported: true, // Métodos de desafio de código PKCE suportados. code_challenge_methods_supported: ["S256"], // Métodos de autenticação suportados pelo endpoint de token. token_endpoint_auth_methods_supported: ["none"], // Escopos OAuth 2.0 que o emissor suporta. 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 arquivo publica a chave pública do emissor usando o método did:web, permitindo que qualquer pessoa verifique a assinatura das credenciais emitidas por ele.

Crie o arquivo 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 = { // O contexto define o vocabulário usado no documento. "@context": [ "https://www.w3.org/ns/did/v1", "https://w3id.org/security/suites/jws-2020/v1", ], // A URI do DID, que é o identificador único para o emissor. id: didId, // O controlador do DID, que é a entidade que controla o DID. Aqui, é o próprio emissor. controller: didId, // Uma lista de chaves públicas que podem ser usadas para verificar assinaturas do emissor. verificationMethod: [ { // Um identificador único para a chave, no escopo do DID. id: `${didId}#${issuerKey.key_id}`, // O tipo da chave. type: "JsonWebKey2020", // O DID do controlador da chave. controller: didId, // A chave pública no formato JWK. publicKeyJwk: publicKeyJWK, }, ], // Especifica quais chaves podem ser usadas para autenticação (provando o controle do DID). authentication: [`${didId}#${issuerKey.key_id}`], // Especifica quais chaves podem ser usadas para criar credenciais verificáveis. assertionMethod: [`${didId}#${issuerKey.key_id}`], // Uma lista de serviços fornecidos pelo sujeito do DID, como o endpoint do emissor. 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 que sem Cache? Você notará que todos os três endpoints retornam cabeçalhos que impedem agressivamente o cache (Cache-Control: no-cache, Pragma: no-cache, Expires: 0). Esta é uma prática de segurança crítica para documentos de descoberta. As configurações do emissor podem mudar — por exemplo, uma chave criptográfica pode ser rotacionada. Se uma wallet ou cliente armazenasse em cache uma versão antiga do arquivo did.json ou openid-credential-issuer, não conseguiria validar novas credenciais ou interagir com endpoints atualizados. Ao forçar os clientes a buscar uma cópia nova em cada requisição, garantimos que eles sempre tenham as informações mais atualizadas.

4.4.3 Implemente o Endpoint do Esquema da Credencial#

A peça final da nossa infraestrutura voltada para o público é o endpoint do esquema da credencial. Esta rota serve um Esquema JSON que define formalmente a estrutura, os tipos de dados e as restrições da credencial PID que estamos emitindo. Wallets e verificadores podem usar este esquema para validar o conteúdo da credencial.

Crie o arquivo src/app/api/schemas/pid/route.ts com o seguinte conteúdo:

// 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: O Esquema JSON para uma credencial PID pode ser bastante grande e detalhado. Por brevidade, o esquema completo foi truncado. Você pode encontrar o arquivo completo no repositório do projeto.

4.5 Construindo os Endpoints do Backend#

Com o frontend pronto, agora precisamos da lógica do lado do servidor para lidar com o fluxo OpenID4VCI. Começaremos com o primeiro endpoint que o frontend chama: /api/issue/authorize.

4.5.1 /api/issue/authorize: Crie a Oferta de Credencial#

Este endpoint é responsável por pegar os dados do usuário, gerar um código seguro de uso único e construir uma credential_offer que a wallet do usuário possa entender.

Aqui está a lógica principal:

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

Passos chave neste endpoint:

  1. Validar Dados: Primeiro, ele garante que os dados do usuário necessários estejam presentes.
  2. Gerar Códigos: Ele cria um pre-authorized_code único (um UUID) e um tx_code de 4 dígitos (PIN) para uma camada extra de segurança.
  3. Persistir Dados: O pre-authorized_code é armazenado no banco de dados com um tempo de expiração curto. Os dados do usuário e o PIN são armazenados na memória, vinculados ao código.
  4. Construir Oferta: Ele constrói o objeto credential_offer de acordo com a especificação OpenID4VCI. Este objeto informa à wallet onde está o emissor, quais credenciais ele oferece e o código necessário para obtê-las.
  5. Retornar URI: Finalmente, ele cria uma URI de link profundo (openid-credential-offer://...) e a retorna para o frontend, juntamente com o tx_code para o usuário ver.

4.5.2 /api/issue/token: Troque o Código por um Token#

Assim que o usuário escaneia o código QR e insere seu PIN, a wallet faz uma requisição POST para este endpoint. Seu trabalho é validar o pre-authorized_code e o user_pin (PIN) e, se forem válidos, emitir um access token de curta duração.

Crie o arquivo src/app/api/issue/token/route.ts com o seguinte conteúdo:

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

Passos chave neste endpoint:

  1. Validar Tipo de Concessão: Ele garante que a wallet esteja usando o tipo de concessão pre-authorized_code correto.
  2. Validar Código: Ele verifica se o pre-authorized_code existe no banco de dados, não está expirado e não foi usado antes.
  3. Validar PIN: Ele compara o user_pin da wallet com o tx_code que armazenamos anteriormente para garantir que o usuário autorizou a transação.
  4. Gerar Tokens: Ele cria um access_token seguro e um c_nonce (nonce de credencial), que é um valor de uso único para prevenir ataques de repetição no endpoint de credencial.
  5. Criar Sessão: Ele cria um novo registro issuance_sessions no banco de dados, vinculando o access token aos dados do usuário.
  6. Marcar Código como Usado: Para evitar que a mesma oferta seja usada duas vezes, ele marca o pre-authorized_code como usado.
  7. Retornar Token: Ele retorna o access_token e o c_nonce para a wallet.

4.5.3 /api/issue/credential: Emita a Credencial Assinada#

Este é o endpoint final e mais importante. A wallet usa o access token que recebeu do endpoint /token para fazer uma requisição POST autenticada a esta rota. O trabalho deste endpoint é realizar a validação final, criar a credencial assinada criptograficamente e retorná-la para a wallet.

Crie o arquivo src/app/api/issue/credential/route.ts com o seguinte conteúdo:

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

Passos chave neste endpoint:

  1. Validar Token: Ele verifica a existência de um token Bearer válido no cabeçalho Authorization e o usa para procurar a sessão de emissão ativa.
  2. Recuperar Dados do Usuário: Ele recupera os dados de alegações do usuário, que foram armazenados na sessão quando o token foi criado.
  3. Carregar Chave do Emissor: Ele carrega a chave de assinatura ativa do emissor do banco de dados. Em um cenário real, isso seria gerenciado por um sistema de gerenciamento de chaves seguro.
  4. Criar Credencial: Ele chama nossa função auxiliar createJWTVerifiableCredential de src/lib/crypto.ts para construir e assinar o JWT-VC.
  5. Registrar Emissão: Ele salva um registro da credencial emitida no banco de dados para fins de auditoria e revogação.
  6. Retornar Credencial: Ele retorna a credencial assinada para a wallet em uma resposta JSON. A wallet é então responsável por armazená-la com segurança.

5. Executando o Emissor e Próximos Passos#

Agora você tem uma implementação completa, de ponta a ponta, de um emissor de credenciais digitais. Veja como executá-lo localmente e o que você precisa considerar para levá-lo de uma prova de conceito a uma aplicação pronta para produção.

5.1 Como Executar o Exemplo#

  1. Clone o Repositório:

    git clone https://github.com/corbado/digital-credentials-example.git cd digital-credentials-example
  2. Instale as Dependências:

    npm install
  3. Inicie o Banco de Dados: Certifique-se de que o Docker esteja em execução e, em seguida, inicie o contêiner MySQL:

    docker-compose up -d
  4. Configure o Ambiente e Execute o Túnel: Este é o passo mais crítico para o teste local. Como sua wallet móvel precisa se conectar à sua máquina de desenvolvimento pela internet, você deve expor seu servidor local com uma URL HTTPS pública. Usaremos o ngrok para isso.

    a. Inicie o ngrok:

    ngrok http 3000

    b. Copie a URL HTTPS da saída do ngrok (ex: https://string-aleatoria.ngrok.io). c. Crie um arquivo .env.local e defina a URL:

    NEXT_PUBLIC_BASE_URL=https://<sua-url-ngrok>
  5. Execute a Aplicação:

    npm run dev

    Abra seu navegador em http://localhost:3000/issue. Agora você pode preencher o formulário, e o código QR gerado apontará corretamente para sua URL pública do ngrok, permitindo que sua wallet móvel se conecte e receba a credencial.

5.2 A Importância do HTTPS e do ngrok#

Os protocolos de credenciais digitais são construídos com a segurança como principal prioridade. Por esse motivo, as wallets quase sempre se recusarão a se conectar a um emissor por uma conexão insegura (http://). Todo o processo depende de uma conexão HTTPS segura, que é habilitada por um certificado SSL.

Um serviço de túnel como o ngrok resolve ambos os problemas criando uma URL HTTPS pública e segura (com um certificado SSL válido) que encaminha todo o tráfego para seu servidor de desenvolvimento local.
As wallets exigem HTTPS e se recusarão a se conectar a endpoints inseguros (http://). Esta é uma ferramenta essencial para testar qualquer serviço web que precise interagir com dispositivos móveis ou webhooks externos.

5.3 O que está Fora do Escopo deste Tutorial#

Este exemplo é intencionalmente focado no fluxo principal de emissão para facilitar o entendimento. Os seguintes tópicos são considerados fora do escopo:

  • Segurança Pronta para Produção: O emissor é para fins educacionais. Um sistema de produção exigiria um Sistema de Gerenciamento de Chaves (KMS) seguro em vez de armazenar chaves em um banco de dados, tratamento robusto de erros, limitação de taxa e registro de auditoria abrangente.
  • Revogação de Credenciais: Este guia não implementa um mecanismo para revogar credenciais emitidas.
    Embora o esquema inclua uma flag revoked para uso futuro, nenhuma lógica de revogação é fornecida aqui.
  • Fluxo de Código de Autorização: Focamos exclusivamente no fluxo pre-authorized_code. Uma implementação completa do fluxo authorization_code exigiria uma tela de consentimento do usuário e uma lógica OAuth 2.0 mais complexa.
  • Gerenciamento de Usuários: O guia não inclui nenhuma autenticação ou gerenciamento de usuários para o próprio emissor. Presume-se que o usuário já está autenticado e autorizado a receber uma credencial.

6. Conclusão#

É isso! Com algumas páginas de código, agora temos um emissor de credenciais digitais completo, de ponta a ponta, que:

  1. Fornece um frontend amigável para solicitar credenciais.
  2. Implementa o fluxo completo pre-authorized_code do OpenID4VCI.
  3. Expõe todos os endpoints de descoberta necessários para a interoperabilidade da wallet.
  4. Gera e assina uma Credencial Verificável JWT segura e em conformidade com os padrões.

Embora este guia forneça uma base sólida, um emissor pronto para produção exigiria recursos adicionais como gerenciamento robusto de chaves, armazenamento persistente em vez de armazenamentos em memória, revogação de credenciais e fortalecimento abrangente da segurança.
A compatibilidade da wallet também varia; a Sphereon Wallet é recomendada para testes, mas outras wallets podem não suportar o fluxo pré-autorizado como implementado aqui. No entanto, os blocos de construção principais e o fluxo de interação permaneceriam os mesmos. Seguindo esses padrões, você pode construir um emissor seguro e interoperável para qualquer tipo de credencial digital.

7. Recursos#

Aqui estão alguns dos principais recursos, especificações e ferramentas usados ou referenciados neste tutorial:

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

Start Free Trial

Share this article


LinkedInTwitterFacebook