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
See the original blog version in English here.
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:
Recent Articles
📝
Como construir um Verifier de Credenciais Digitais (Guia para Desenvolvedores)
📝
Como construir um Emissor de Credenciais Digitais (Guia para Desenvolvedores)
📖
Chave Residente WebAuthn: Credenciais Detectáveis como Passkeys
🔑
Guia Técnico: Acesso com Crachá Físico e Passkeys
🔑
Tornando o MFA Obrigatório e Adotando Passkeys: Melhores Práticas
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:
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.
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:
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.
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.
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 / Protocolo | Descrição |
---|---|
OpenID4VCI | OpenID 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-VC | Credenciais 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 mDoc | ISO/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.0 | O 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. |
O OpenID4VCI suporta dois fluxos de autorização primários para a emissão de credenciais:
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.
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.
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.
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.
Next.js é nosso framework de escolha porque proporciona uma experiência integrada e fluida para a construção de aplicações full-stack.
Nossa implementação dependerá de algumas bibliotecas principais para lidar com tarefas específicas:
pre-authorized_code
.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:
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.
No seu cerne, uma Credencial Verificável é um conjunto de alegações que foi assinado digitalmente pelo Emissor. Essa assinatura oferece duas garantias:
As assinaturas digitais são criadas usando criptografia de chave pública/privada. Veja como funciona:
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.
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.
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.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.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:
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
Primeiro, vamos inicializar um novo projeto Next.js, instalar as dependências necessárias e iniciar nosso banco de dados.
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.
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
.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.
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.
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.
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.
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/...
):
.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.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.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.
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:
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:
POST
para nosso endpoint /api/issue/authorize
com os dados
do usuário.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.
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.
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
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.
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.
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
.
/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:
pre-authorized_code
único (um UUID) e um tx_code
de
4 dígitos (PIN) para uma camada extra de segurança.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.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.openid-credential-offer://...
) e a retorna para o frontend, juntamente com o
tx_code
para o usuário ver./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:
pre-authorized_code
correto.pre-authorized_code
existe no banco de dados,
não está expirado e não foi usado antes.user_pin
da wallet com o tx_code
que armazenamos
anteriormente para garantir que o usuário autorizou a transação.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.issuance_sessions
no banco de dados,
vinculando o access token aos dados do usuário.pre-authorized_code
como usado.access_token
e o c_nonce
para a wallet./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:
Bearer
válido no cabeçalho
Authorization
e o usa para procurar a sessão de emissão ativa.createJWTVerifiableCredential
de src/lib/crypto.ts
para construir e assinar o JWT-VC.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.
Clone o Repositório:
git clone https://github.com/corbado/digital-credentials-example.git cd digital-credentials-example
Instale as Dependências:
npm install
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
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>
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.
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.
Este exemplo é intencionalmente focado no fluxo principal de emissão para facilitar o entendimento. Os seguintes tópicos são considerados fora do escopo:
revoked
para uso futuro, nenhuma lógica de revogação
é fornecida aqui.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.É isso! Com algumas páginas de código, agora temos um emissor de credenciais digitais completo, de ponta a ponta, que:
pre-authorized_code
do OpenID4VCI.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.
Aqui estão alguns dos principais recursos, especificações e ferramentas usados ou referenciados neste tutorial:
Repositório do Projeto:
Especificações Principais:
did:web
Method: O método DID
usado para a chave pública do nosso emissor.Ferramentas:
Bibliotecas:
Related Articles
Table of Contents