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

Comment créer un émetteur d'identifiants numériques (Guide du développeur)

Apprenez à construire un émetteur de Verifiable Credentials W3C en utilisant le protocole OpenID4VCI. Ce guide étape par étape vous montre comment créer une application Next.js qui émet des identifiants signés cryptographiquement, compatibles avec les wal

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

Les identifiants numériques sont un moyen puissant de prouver une identité et des attestations de manière sécurisée et respectueuse de la vie privée. Mais comment les utilisateurs obtiennent-ils ces identifiants ? C'est là que le rôle de l'Issuer (émetteur) devient crucial. Un Issuer est une entité de confiance, comme une agence gouvernementale, une université ou une banque, chargée de créer et de distribuer des identifiants signés numériquement aux utilisateurs.

Ce guide propose un tutoriel complet, étape par étape, pour construire un Issuer d'identifiants numériques. Nous nous concentrerons sur le protocole OpenID for Verifiable Credential Issuance (OpenID4VCI), une norme moderne qui définit comment les utilisateurs peuvent obtenir des identifiants auprès d'un Issuer et les stocker en toute sécurité dans leurs wallets numériques.

Le résultat final sera une application Next.js fonctionnelle capable de :

  1. Accepter les données de l'utilisateur via un simple formulaire web.
  2. Générer une offre d'identifiant sécurisée et à usage unique.
  3. Afficher l'offre sous forme de QR code que l'utilisateur peut scanner avec son wallet mobile.
  4. Émettre un identifiant signé cryptographiquement que l'utilisateur peut stocker et présenter pour vérification.

1.1 Comprendre la terminologie : Identifiants numériques vs. Verifiable Credentials#

Avant de continuer, il est important de clarifier la distinction entre deux concepts liés mais différents :

  • Identifiants numériques (terme général) : Il s'agit d'une vaste catégorie qui englobe toute forme numérique d'identifiants, de certificats ou d'attestations. Cela peut inclure de simples certificats numériques, des badges numériques de base, ou tout identifiant stocké électroniquement qui peut ou non avoir des fonctionnalités de sécurité cryptographique.

  • Verifiable Credentials (VC - Standard W3C) : Il s'agit d'un type spécifique d'identifiant numérique qui suit la norme W3C Verifiable Credentials Data Model. Les Verifiable Credentials sont des identifiants signés cryptographiquement, infalsifiables et respectueux de la vie privée, qui peuvent être vérifiés de manière indépendante. Ils incluent des exigences techniques spécifiques comme :

    • Des signatures cryptographiques pour l'authenticité et l'intégrité
    • Un modèle de données et des formats standardisés
    • Des mécanismes de présentation respectueux de la vie privée
    • Des protocoles de vérification interopérables

Dans ce guide, nous construisons spécifiquement un émetteur de Verifiable Credentials qui suit la norme W3C, et non un simple système d'identifiants numériques. Le protocole OpenID4VCI que nous utilisons est conçu spécifiquement pour l'émission de Verifiable Credentials, et le format JWT-VC que nous mettrons en œuvre est un format conforme au W3C pour les Verifiable Credentials.

1.2 Comment ça marche#

La magie des identifiants numériques repose sur un modèle simple mais puissant de « triangle de confiance » impliquant trois acteurs clés :

  • Issuer (Émetteur) : Une autorité de confiance (par exemple, une agence gouvernementale, une université ou une banque) qui signe cryptographiquement et émet un identifiant à un utilisateur. C'est le rôle que nous construisons dans ce guide.
  • Holder (Détenteur) : L'utilisateur, qui reçoit l'identifiant et le stocke en toute sécurité dans un wallet numérique personnel sur son appareil.
  • Verifier (Vérificateur) : Une application ou un service qui doit vérifier l'identifiant de l'utilisateur.

Le flux d'émission est la première étape de cet écosystème. L'Issuer valide les informations de l'utilisateur et lui fournit un identifiant. Une fois que le Holder a cet identifiant dans son wallet, il peut le présenter à un Verifier pour prouver son identité ou ses attestations, complétant ainsi le triangle.

Voici un aperçu rapide de l'application finale en action :

Étape 1 : Saisie des données de l'utilisateur L'utilisateur remplit un formulaire avec ses informations personnelles pour demander un nouvel identifiant.

Étape 2 : Génération de l'offre d'identifiant L'application génère une offre d'identifiant sécurisée, affichée sous forme de QR code et d'un code pré-autorisé.

Étape 3 : Interaction avec le wallet L'utilisateur scanne le QR code avec un wallet compatible (par exemple, Sphereon Wallet) et saisit un code PIN pour autoriser l'émission.

Étape 4 : Identifiant émis Le wallet reçoit et stocke le nouvel identifiant numérique émis, prêt pour une utilisation future.

2. Prérequis pour construire un Issuer#

Avant de plonger dans le code, passons en revue les connaissances fondamentales et les outils dont nous aurons besoin. Ce guide suppose une familiarité de base avec les concepts de développement web, mais les prérequis suivants sont essentiels pour construire un émetteur d'identifiants.

2.1 Choix des protocoles#

Notre Issuer est basé sur un ensemble de normes ouvertes qui garantissent l'interopérabilité entre les wallets et les services d'émission. Pour ce tutoriel, nous nous concentrerons sur les éléments suivants :

Norme / ProtocoleDescription
OpenID4VCIOpenID for Verifiable Credential Issuance. C'est le protocole principal que nous utiliserons. Il définit un flux standard pour la manière dont un utilisateur (via son wallet) peut demander et recevoir un identifiant d'un Issuer.
JWT-VCVerifiable Credentials basés sur JWT. Le format de l'identifiant que nous émettrons. C'est une norme W3C qui encode les Verifiable Credentials en tant que JSON Web Tokens (JWT), les rendant compacts et adaptés au web.
ISO mDocISO/IEC 18013-5. La norme internationale pour les permis de conduire mobiles (mDL). Bien que nous émettions un JWT-VC, les claims qu'il contient sont structurés pour être compatibles avec le modèle de données mDoc (par exemple, eu.europa.ec.eudi.pid.1).
OAuth 2.0Le framework d'autorisation sous-jacent utilisé par OpenID4VCI. Nous mettrons en œuvre un flux pre-authorized_code, qui est un type de subvention spécifique conçu pour une émission d'identifiants sécurisée et conviviale.

2.1.1 Flux d'autorisation : Code pré-autorisé vs. Code d'autorisation#

OpenID4VCI prend en charge deux flux d'autorisation principaux pour l'émission d'identifiants :

  1. Flux de code pré-autorisé : Dans ce flux, l'Issuer génère un code à usage unique et de courte durée (pre-authorized_code) qui est immédiatement disponible pour l'utilisateur. Le wallet de l'utilisateur peut ensuite échanger ce code directement contre un identifiant. Ce flux est idéal pour les scénarios où l'utilisateur est déjà authentifié et présent sur le site web de l'Issuer, car il offre une expérience d'émission instantanée et transparente, sans redirection.

  2. Flux de code d'autorisation : Il s'agit du flux OAuth 2.0 standard, où l'utilisateur est redirigé vers un serveur d'autorisation pour donner son consentement. Après approbation, le serveur renvoie un authorization_code à un redirect_uri enregistré. Ce flux est plus adapté aux applications tierces qui lancent le processus d'émission au nom de l'utilisateur.

Pour ce tutoriel, nous utiliserons le flux pre-authorized_code. Nous avons choisi cette approche car elle est plus simple et offre une expérience utilisateur plus directe pour notre cas d'usage spécifique : un utilisateur demandant directement un identifiant sur le site web de l'Issuer. Cela élimine le besoin de redirections complexes et d'enregistrement de client, rendant la logique d'émission de base plus facile à comprendre et à mettre en œuvre.

Cette combinaison de normes nous permet de construire un émetteur compatible avec un large éventail de wallets numériques et garantit un processus sécurisé et standardisé pour l'utilisateur.

2.2 Choix de la stack technique#

Pour construire notre émetteur, nous utiliserons la même stack technique robuste et moderne que celle utilisée pour le vérificateur, garantissant une expérience de développement cohérente et de haute qualité.

2.2.1 Langage : TypeScript#

Nous utiliserons TypeScript pour notre code frontend et backend. Son typage statique est inestimable dans une application critique pour la sécurité comme un émetteur, car il aide à prévenir les erreurs courantes et améliore la qualité et la maintenabilité globales du code.

2.2.2 Framework : Next.js#

Next.js est notre framework de prédilection car il offre une expérience intégrée et transparente pour la création d'applications full-stack.

  • Pour le frontend : Nous utiliserons Next.js avec React pour construire l'interface utilisateur où les utilisateurs peuvent saisir leurs données pour demander un identifiant.
  • Pour le backend : Nous tirerons parti des Next.js API Routes pour créer les points de terminaison côté serveur qui gèrent le flux OpenID4VCI, de la génération des offres d'identifiants à l'émission de l'identifiant final signé.

2.2.3 Bibliothèques clés#

Notre implémentation s'appuiera sur quelques bibliothèques clés pour gérer des tâches spécifiques :

  • next, react et react-dom : Les bibliothèques de base pour notre application Next.js.
  • mysql2 : Un client MySQL pour Node.js, utilisé pour stocker les codes d'autorisation et les données de session.
  • uuid : Une bibliothèque pour générer des identifiants uniques, que nous utiliserons pour créer les valeurs pre-authorized_code.
  • jose : Une bibliothèque robuste pour gérer les JSON Web Signatures (JWS), que nous utiliserons pour signer cryptographiquement les identifiants que nous émettons.

2.3 Obtenir un wallet de test#

Pour tester votre émetteur, vous aurez besoin d'un wallet mobile qui prend en charge le protocole OpenID4VCI. Pour ce tutoriel, nous recommandons le Sphereon Wallet, qui est disponible pour Android et iOS.

Comment installer Sphereon Wallet :

  1. Téléchargez le wallet depuis le Google Play Store ou l'Apple App Store.
  2. Installez l'application sur votre appareil mobile.
  3. Une fois installé, le wallet est prêt à recevoir des offres d'identifiants en scannant un QR code.

2.4 Connaissances en cryptographie#

L'émission d'un identifiant est une opération critique pour la sécurité qui repose sur des concepts cryptographiques fondamentaux pour garantir la confiance et l'authenticité.

2.4.1 Signatures numériques#

Au cœur, un Verifiable Credential est un ensemble d'attestations qui a été signé numériquement par l'Issuer. Cette signature offre deux garanties :

  • Authenticité : Elle prouve que l'identifiant a été créé par un Issuer légitime.
  • Intégrité : Elle prouve que l'identifiant n'a pas été modifié depuis son émission.

2.4.2 Cryptographie à clé publique/privée#

Les signatures numériques sont créées à l'aide de la cryptographie à clé publique/privée. Voici comment cela fonctionne :

  1. L'Issuer possède une paire de clés : une clé privée, qui est gardée secrète et sécurisée, et une clé publique correspondante, qui est rendue publiquement disponible.
  2. Signature : Lorsque l'Issuer crée un identifiant, il utilise sa clé privée pour générer une signature numérique unique pour les données de l'identifiant.
  3. Vérification : Un Verifier peut ensuite utiliser la clé publique de l'Issuer pour vérifier la signature. Si la vérification réussit, le Verifier sait que l'identifiant est authentique et n'a pas été altéré.

Dans notre implémentation, nous générerons une paire de clés à courbe elliptique (EC) et utiliserons l'algorithme ES256 pour signer le JWT-VC. La clé publique est intégrée dans le DID de l'Issuer (did:web), permettant à n'importe quel Verifier de la découvrir et de valider la signature de l'identifiant.
Remarque : Le claim aud (audience) est intentionnellement omis dans nos JWT, car l'identifiant est conçu pour être polyvalent et non lié à un wallet spécifique.
Si vous souhaitez restreindre l'utilisation à une audience particulière, incluez un claim aud et définissez-le en conséquence.

3. Aperçu de l'architecture#

Notre application Issuer est construite comme un projet Next.js full-stack, avec une séparation claire entre la logique frontend et backend. Cette architecture nous permet de créer une expérience utilisateur transparente tout en gérant toutes les opérations critiques pour la sécurité sur le serveur.
Important : Les tables verification_sessions et verified_credentials incluses dans le SQL ne sont pas requises pour cet émetteur mais sont incluses pour être complet.

  • Frontend (src/app/issue/page.tsx) : Une seule page React qui permet aux utilisateurs de saisir leurs données pour demander un identifiant. Elle effectue des appels API vers notre backend pour lancer le processus d'émission.
  • Routes API Backend (src/app/api/issue/...) : Un ensemble de points de terminaison côté serveur qui implémentent le protocole OpenID4VCI.
    • /.well-known/openid-credential-issuer : Un point de terminaison de métadonnées public. C'est la première URL qu'un wallet vérifiera pour découvrir les capacités de l'émetteur, y compris son serveur d'autorisation, son point de terminaison de jeton, son point de terminaison d'identifiant et les types d'identifiants qu'il propose.
    • /.well-known/openid-configuration : Un point de terminaison de découverte OpenID Connect standard. Bien que étroitement lié au précédent, ce point de terminaison sert une configuration plus large liée à OIDC et est souvent requis pour l'interopérabilité avec les clients OpenID standard.
    • /.well-known/did.json : Le document DID pour notre émetteur. Lors de l'utilisation de la méthode did:web, ce fichier est utilisé pour publier les clés publiques de l'émetteur, que les vérificateurs peuvent utiliser pour valider les signatures des identifiants qu'il émet.
    • authorize/route.ts : Crée un pre-authorized_code et une offre d'identifiant.
    • token/route.ts : Échange le pre-authorized_code contre un access token.
    • credential/route.ts : Émet le JWT-VC final, signé cryptographiquement.
    • schemas/pid/route.ts : Expose le schéma JSON pour l'identifiant PID. Cela permet à tout consommateur de l'identifiant de comprendre sa structure et ses types de données.
  • Bibliothèque (src/lib/) :
    • database.ts : Gère toutes les interactions avec la base de données, comme le stockage des codes d'autorisation et des clés de l'émetteur.
    • crypto.ts : Gère toutes les opérations cryptographiques, y compris la génération de clés et la signature JWT.

Voici un diagramme illustrant le flux d'émission :

4. Construire l'Issuer#

Maintenant que nous avons une solide compréhension des normes, des protocoles et de l'architecture, nous pouvons commencer à construire notre émetteur.

Suivez pas à pas ou utilisez le code final

Nous allons maintenant passer en revue la configuration et l'implémentation du code étape par étape. Si vous préférez passer directement au produit fini, vous pouvez cloner le projet complet depuis notre dépôt GitHub et l'exécuter localement.

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

4.1 Mise en place du projet#

Tout d'abord, nous allons initialiser un nouveau projet Next.js, installer les dépendances nécessaires et démarrer notre base de données.

4.1.1 Initialisation de l'application Next.js#

Ouvrez votre terminal, naviguez vers le répertoire où vous souhaitez créer votre projet, et exécutez la commande suivante. Nous utilisons l'App Router, TypeScript et Tailwind CSS pour ce projet.

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

Cette commande crée un nouveau squelette d'application Next.js dans votre répertoire actuel.

4.1.2 Installation des dépendances#

Ensuite, nous devons installer les bibliothèques qui géreront les JWT, les connexions à la base de données et la génération d'UUID.

npm install jose mysql2 uuid @types/uuid

Cette commande installe :

  • jose : Pour signer et vérifier les JSON Web Tokens (JWT).
  • mysql2 : Le client MySQL pour notre base de données.
  • uuid : Pour générer des chaînes de défi uniques.
  • @types/uuid : Les types TypeScript pour la bibliothèque uuid.

4.1.3 Démarrage de la base de données#

Notre backend nécessite une base de données MySQL pour stocker les codes d'autorisation, les sessions d'émission et les clés de l'émetteur. Nous avons inclus un fichier docker-compose.yml pour faciliter cela.

Si vous avez cloné le dépôt, vous pouvez simplement exécuter docker-compose up -d. Si vous partez de zéro, créez un fichier nommé docker-compose.yml avec le contenu suivant :

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:

Cette configuration Docker Compose nécessite également un script d'initialisation SQL. Créez un répertoire nommé sql et à l'intérieur, un fichier nommé init.sql avec le contenu suivant pour configurer les tables nécessaires pour le vérificateur et l'émetteur :

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

Une fois les deux fichiers en place, ouvrez votre terminal à la racine du projet et exécutez :

docker-compose up -d

Cette commande démarrera un conteneur MySQL en arrière-plan, prêt à être utilisé par notre application.

4.2 Implémentation des bibliothèques partagées#

Avant de construire les points de terminaison de l'API, créons les bibliothèques partagées qui géreront la logique métier de base. Cette approche maintient nos routes API propres et axées sur la gestion des requêtes HTTP, tandis que le travail complexe est délégué à ces modules.

4.2.1 La bibliothèque de base de données (src/lib/database.ts)#

Ce fichier est la seule source de vérité pour toutes les interactions avec la base de données. Il utilise la bibliothèque mysql2 pour se connecter à notre conteneur MySQL et fournit un ensemble de fonctions exportées pour créer, lire et mettre à jour des enregistrements dans nos tables. Cette couche d'abstraction rend notre code plus modulaire et plus facile à maintenir.

Créez le fichier src/lib/database.ts avec le contenu suivant :

// src/lib/database.ts import mysql from "mysql2/promise"; // Configuration de la connexion à la base de données 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; } // Fonctions Data-Access-Object (DAO) pour chaque table // ... (par ex., createChallenge, getChallenge, createAuthorizationCode, etc.)

Remarque : Pour des raisons de brièveté, la liste complète des fonctions DAO a été omise. Vous pouvez trouver le code complet dans le dépôt du projet. Ce fichier inclut des fonctions pour gérer les défis, les sessions de vérification, les codes d'autorisation, les sessions d'émission et les clés de l'émetteur.

4.2.2 La bibliothèque de cryptographie (src/lib/crypto.ts)#

Ce fichier gère toutes les opérations cryptographiques critiques pour la sécurité. Il utilise la bibliothèque jose pour générer des paires de clés et signer des JSON Web Tokens (JWT).

Génération de clés La fonction generateIssuerKeyPair crée une nouvelle paire de clés à courbe elliptique qui sera utilisée pour signer les identifiants. La clé publique est exportée au format JSON Web Key (JWK) afin qu'elle puisse être publiée dans notre document 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; // Attribuer un ID de clé unique // ... (exportation de la clé privée et autre configuration) return { publicKey, privateKey, publicKeyJWK /* ... */ }; }

Création d'identifiants JWT La fonction createJWTVerifiableCredential est au cœur du processus d'émission. Elle prend les attestations de l'utilisateur, la paire de clés de l'émetteur et d'autres métadonnées, et les utilise pour créer un JWT-VC signé.

// 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 = { // Le DID de l'émetteur iss: issuerKeyPair.issuerDid, // Le DID du sujet (détenteur) sub: subjectId, // L'heure à laquelle l'identifiant a été émis (iat) et quand il expire (exp) iat: now, exp: now + oneYear, // Le modèle de données du Verifiable Credential 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, }, }, }; // Signer la charge utile avec la clé privée de l'émetteur return await new SignJWT(vcPayload) .setProtectedHeader({ alg: issuerKeyPair.algorithm, kid: issuerKeyPair.keyId, typ: "JWT", }) .sign(issuerKeyPair.privateKey); }

Cette fonction construit la charge utile du JWT conformément au modèle de données W3C Verifiable Credentials et la signe avec la clé privée de l'émetteur, produisant un Verifiable Credential sécurisé et vérifiable.

4.2 Aperçu de l'architecture de l'application Next.js#

Notre application Next.js est structurée pour séparer les responsabilités entre le frontend et le backend, même s'ils font partie du même projet. Ceci est réalisé en tirant parti de l'App Router pour les pages d'interface utilisateur et les points de terminaison de l'API.

  • Frontend (src/app/issue/page.tsx) : Un seul composant de page React qui définit l'interface utilisateur pour la route /issue. Il gère la saisie de l'utilisateur et communique avec notre API backend.

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

    • Découverte (.well-known/.../route.ts) : Ces routes exposent des points de terminaison de métadonnées publics qui permettent aux wallets et autres clients de découvrir les capacités de l'émetteur et ses clés publiques.
    • Émission (issue/.../route.ts) : Ces points de terminaison implémentent la logique de base d'OpenID4VCI, y compris la création d'offres d'identifiants, l'émission de jetons et la signature de l'identifiant final.
    • Schéma (schemas/pid/route.ts) : Cette route sert le schéma JSON pour l'identifiant, définissant sa structure.
  • Bibliothèque (src/lib/) : Ce répertoire contient une logique réutilisable partagée à travers le backend.

    • database.ts : Gère toutes les interactions avec la base de données, en abstrayant les requêtes SQL.
    • crypto.ts : Gère toutes les opérations cryptographiques, telles que la génération de clés et la signature JWT.

Cette séparation claire rend l'application modulaire et plus facile à maintenir.

Remarque : La fonction generateIssuerDid() doit retourner un did:web valide correspondant à votre domaine d'émetteur.
Lors du déploiement, le .well-known/did.json doit être servi via HTTPS sur ce domaine pour que les vérificateurs puissent valider les identifiants.

4.3 Construire le frontend#

Notre frontend est une seule page React qui fournit un formulaire simple pour que les utilisateurs demandent un nouvel identifiant numérique. Ses responsabilités sont de :

  • Capturer les données de l'utilisateur (nom, date de naissance, etc.).
  • Envoyer ces données à notre backend pour créer une offre d'identifiant.
  • Afficher le QR code et le code PIN résultants pour que l'utilisateur les scanne avec son wallet.

La logique de base est gérée dans la fonction handleSubmit, qui est déclenchée lorsque l'utilisateur soumet le formulaire.

// src/app/issue/page.tsx const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setLoading(true); setError(null); setCredentialOffer(null); try { // 1. Valider les champs requis if (!userData.given_name || !userData.family_name || !userData.birth_date) { throw new Error("Veuillez remplir tous les champs requis"); } // 2. Demander une offre d'identifiant au 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 || "Échec de la création de l'offre d'identifiant", ); } // 3. Définir l'offre d'identifiant dans l'état pour afficher le QR code const result = await response.json(); setCredentialOffer(result); } catch (err) { const errorMessage = (err as Error).message || "Une erreur inconnue s'est produite"; setError(errorMessage); } finally { setLoading(false); } };

Cette fonction effectue trois actions clés :

  1. Valide les données du formulaire pour s'assurer que tous les champs requis sont remplis.
  2. Envoie une requête POST à notre point de terminaison /api/issue/authorize avec les données de l'utilisateur.
  3. Met à jour l'état du composant avec l'offre d'identifiant reçue du backend, ce qui déclenche l'affichage du QR code et du code de transaction dans l'interface utilisateur.

Le reste du fichier contient du code React standard pour le rendu du formulaire et l'affichage du QR code. Vous pouvez voir le fichier complet dans le dépôt du projet.

4.4 Configurer l'environnement et la découverte#

Avant de construire l'API backend, nous devons configurer notre environnement et mettre en place les points de terminaison de découverte. Ces fichiers .well-known sont cruciaux pour que les wallets trouvent notre émetteur et comprennent comment interagir avec lui.

4.4.1 Créer le fichier d'environnement#

Créez un fichier nommé .env.local à la racine de votre projet et ajoutez la ligne suivante. Cette URL doit être publiquement accessible pour qu'un wallet mobile puisse l'atteindre. Pour le développement local, vous pouvez utiliser un service de tunnel comme ngrok pour exposer votre localhost.

NEXT_PUBLIC_BASE_URL=http://localhost:3000

4.4.2 Implémenter les points de terminaison de découverte#

Les wallets découvrent les capacités d'un émetteur en interrogeant des URL .well-known standard. Nous devons créer trois de ces points de terminaison.

1. Métadonnées de l'émetteur (/.well-known/openid-credential-issuer)

C'est le fichier de découverte principal pour OpenID4VCI. Il indique au wallet tout ce qu'il doit savoir sur l'émetteur, y compris ses points de terminaison, les types d'identifiants qu'il propose et les algorithmes cryptographiques pris en charge.

Créez le fichier 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 = { // L'identifiant unique de l'émetteur. issuer: baseUrl, // L'URL du serveur d'autorisation. Pour simplifier, notre émetteur est son propre serveur d'autorisation. authorization_servers: [baseUrl], // L'URL de l'émetteur d'identifiants. credential_issuer: baseUrl, // Le point de terminaison où le wallet enverra une requête POST pour recevoir l'identifiant réel. credential_endpoint: `${baseUrl}/api/issue/credential`, // Le point de terminaison où le wallet échange un code d'autorisation contre un jeton d'accès. token_endpoint: `${baseUrl}/api/issue/token`, // Le point de terminaison pour le flux d'autorisation (non utilisé dans notre flux pré-autorisé, mais bonne pratique de l'inclure). authorization_endpoint: `${baseUrl}/api/issue/authorize`, // Indique la prise en charge du flux de code pré-autorisé sans nécessiter d'authentification du client. pre_authorized_grant_anonymous_access_supported: true, // Informations lisibles par l'homme sur l'émetteur. display: [ { name: "Corbado Credentials Issuer", locale: "en-US", }, ], // Une liste des types d'identifiants que cet émetteur peut émettre. credential_configurations_supported: { "eu.europa.ec.eudi.pid.1": { // Le format de l'identifiant (par ex., jwt_vc, mso_mdoc). format: "jwt_vc", // Le type de document spécifique, conforme aux normes ISO mDoc. doctype: "eu.europa.ec.eudi.pid.1", // La portée OAuth 2.0 associée à ce type d'identifiant. scope: "eu.europa.ec.eudi.pid.1", // Méthodes que le wallet peut utiliser pour prouver la possession de sa clé. cryptographic_binding_methods_supported: ["jwk"], // Algorithmes de signature que l'émetteur prend en charge pour cet identifiant. credential_signing_alg_values_supported: ["ES256"], // Types de preuve de possession que le wallet peut utiliser. proof_types_supported: { jwt: { proof_signing_alg_values_supported: ["ES256", "ES384", "ES512"], }, }, // Propriétés d'affichage pour l'identifiant. display: [ { name: "Corbado Credential Issuer", locale: "en-US", logo: { uri: `${baseUrl}/logo.png`, alt_text: "EU Digital Identity", }, background_color: "#003399", text_color: "#FFFFFF", }, ], // Une liste des attestations (attributs) dans l'identifiant. 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éthodes d'authentification prises en charge par le point de terminaison de jeton. 'none' signifie client public. token_endpoint_auth_methods_supported: ["none"], // Méthodes de défi de code PKCE prises en charge. code_challenge_methods_supported: ["S256"], // Types de subvention OAuth 2.0 que l'émetteur prend en charge. 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. Configuration OpenID (/.well-known/openid-configuration)

Il s'agit d'un document de découverte OIDC standard qui fournit un ensemble plus large de détails de configuration.

Créez le fichier 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 = { // L'identifiant unique de l'émetteur. credential_issuer: baseUrl, // Le point de terminaison où le wallet enverra une requête POST pour recevoir l'identifiant réel. credential_endpoint: `${baseUrl}/api/issue/credential`, // Le point de terminaison pour le flux d'autorisation. authorization_endpoint: `${baseUrl}/api/issue/authorize`, // Le point de terminaison où le wallet échange un code d'autorisation contre un jeton d'accès. token_endpoint: `${baseUrl}/api/issue/token`, // Une liste des types d'identifiants que cet émetteur peut émettre. 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"], }, }, }, }, // Types de subvention OAuth 2.0 que l'émetteur prend en charge. grant_types_supported: [ "authorization_code", "urn:ietf:params:oauth:grant-type:pre-authorized_code", ], // Indique la prise en charge du flux de code pré-autorisé. pre_authorized_grant_anonymous_access_supported: true, // Méthodes de défi de code PKCE prises en charge. code_challenge_methods_supported: ["S256"], // Méthodes d'authentification prises en charge par le point de terminaison de jeton. token_endpoint_auth_methods_supported: ["none"], // Portées OAuth 2.0 que l'émetteur prend en charge. 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. Document DID (/.well-known/did.json)

Ce fichier publie la clé publique de l'émetteur en utilisant la méthode did:web, permettant à quiconque de vérifier la signature des identifiants qu'il a émis.

Créez le fichier 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 = { // Le contexte définit le vocabulaire utilisé dans le document. "@context": [ "https://www.w3.org/ns/did/v1", "https://w3id.org/security/suites/jws-2020/v1", ], // L'URI du DID, qui est l'identifiant unique de l'émetteur. id: didId, // Le contrôleur du DID, qui est l'entité qui contrôle le DID. Ici, c'est l'émetteur lui-même. controller: didId, // Une liste de clés publiques qui peuvent être utilisées pour vérifier les signatures de l'émetteur. verificationMethod: [ { // Un identifiant unique pour la clé, dans le périmètre du DID. id: `${didId}#${issuerKey.key_id}`, // Le type de la clé. type: "JsonWebKey2020", // Le DID du contrôleur de la clé. controller: didId, // La clé publique au format JWK. publicKeyJwk: publicKeyJWK, }, ], // Spécifie quelles clés peuvent être utilisées pour l'authentification (prouver le contrôle du DID). authentication: [`${didId}#${issuerKey.key_id}`], // Spécifie quelles clés peuvent être utilisées pour créer des Verifiable Credentials. assertionMethod: [`${didId}#${issuerKey.key_id}`], // Une liste de services fournis par le sujet du DID, comme le point de terminaison de l'émetteur. 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", }, }); }

Pourquoi ne pas utiliser de cache ? Vous remarquerez que ces trois points de terminaison retournent des en-têtes qui empêchent agressivement la mise en cache (Cache-Control: no-cache, Pragma: no-cache, Expires: 0). C'est une pratique de sécurité essentielle pour les documents de découverte. Les configurations de l'émetteur peuvent changer, par exemple, une clé cryptographique peut être renouvelée. Si un wallet ou un client mettait en cache une ancienne version du fichier did.json ou openid-credential-issuer, il ne parviendrait pas à valider les nouveaux identifiants ou à interagir avec les points de terminaison mis à jour. En forçant les clients à récupérer une nouvelle copie à chaque requête, nous nous assurons qu'ils disposent toujours des informations les plus récentes.

4.4.3 Implémenter le point de terminaison du schéma de l'identifiant#

La dernière pièce de notre infrastructure publique est le point de terminaison du schéma de l'identifiant. Cette route sert un schéma JSON qui définit formellement la structure, les types de données et les contraintes de l'identifiant PID que nous émettons. Les wallets et les vérificateurs peuvent utiliser ce schéma pour valider le contenu de l'identifiant.

Créez le fichier src/app/api/schemas/pid/route.ts avec le contenu suivant :

// 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", // Remplacez par votre domaine réel 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" }, // ... autres propriétés du sujet de l'identifiant }, required: ["given_name", "family_name", "birth_date"], }, // ... autres propriétés de haut niveau d'un Verifiable Credential }, }; return NextResponse.json(schema, { headers: { "Content-Type": "application/schema+json", "Access-Control-Allow-Origin": "*", // Autoriser les requêtes cross-origin }, }); }

Remarque : Le schéma JSON pour un identifiant PID peut être assez volumineux et détaillé. Pour des raisons de brièveté, le schéma complet a été tronqué. Vous pouvez trouver le fichier complet dans le dépôt du projet.

4.5 Construire les points de terminaison du backend#

Avec le frontend en place, nous avons maintenant besoin de la logique côté serveur pour gérer le flux OpenID4VCI. Nous commencerons par le premier point de terminaison que le frontend appelle : /api/issue/authorize.

4.5.1 /api/issue/authorize : Créer l'offre d'identifiant#

Ce point de terminaison est chargé de prendre les données de l'utilisateur, de générer un code sécurisé à usage unique et de construire une credential_offer que le wallet de l'utilisateur peut comprendre.

Voici la logique de base :

// 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. Valider les données de l'utilisateur 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. Générer un code pré-autorisé et un code PIN const code = uuidv4(); const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes const txCode = Math.floor(1000 + Math.random() * 9000).toString(); // PIN à 4 chiffres // 3. Stocker le code et les données de l'utilisateur await createAuthorizationCode(uuidv4(), code, expiresAt); // Remarque : Ceci utilise un stockage en mémoire à des fins de démonstration uniquement. // En production, persistez les données de manière sécurisée dans une base de données avec une expiration appropriée. 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. Créer l'objet de l'offre d'identifiant const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3000"; const credentialOffer = { // L'identifiant de l'émetteur, qui est son URL de base. credential_issuer: baseUrl, // Un tableau des types d'identifiants que l'émetteur propose. credential_configuration_ids: ["eu.europa.ec.eudi.pid.1"], // Spécifie les types de subvention que le wallet peut utiliser. grants: { // Nous utilisons le flux de code pré-autorisé. "urn:ietf:params:oauth:grant-type:pre-authorized_code": { // Le code à usage unique que le wallet échangera contre un jeton. "pre-authorized_code": code, // Indique que l'utilisateur doit entrer un code PIN (tx_code) pour utiliser le code. user_pin_required: true, }, }, }; // 5. Créer l'URI complète de l'offre d'identifiant (un lien profond pour les wallets) const credentialOfferUri = `openid-credential-offer://?credential_offer=${encodeURIComponent( JSON.stringify(credentialOffer), )}`; // La réponse finale au frontend. return NextResponse.json({ // Le lien profond pour le QR code. credential_offer_uri: credentialOfferUri, // Le code pré-autorisé brut, pour affichage ou saisie manuelle. pre_authorized_code: code, // Le code PIN à 4 chiffres que l'utilisateur doit entrer dans son wallet. tx_code: txCode, }); } catch (error) { console.error("Authorization error:", error); return NextResponse.json({ error: "server_error" }, { status: 500 }); } }

Étapes clés de ce point de terminaison :

  1. Validation des données : Il s'assure d'abord que les données utilisateur requises sont présentes.
  2. Génération de codes : Il crée un pre-authorized_code unique (un UUID) et un tx_code à 4 chiffres (PIN) pour une couche de sécurité supplémentaire.
  3. Persistance des données : Le pre-authorized_code est stocké dans la base de données avec un court délai d'expiration. Les données de l'utilisateur et le PIN sont stockés en mémoire, liés au code.
  4. Construction de l'offre : Il construit l'objet credential_offer conformément à la spécification OpenID4VCI. Cet objet indique au wallet où se trouve l'émetteur, quels identifiants il propose et le code nécessaire pour les obtenir.
  5. Retour de l'URI : Enfin, il crée une URI de lien profond (openid-credential-offer://...) et la retourne au frontend, avec le tx_code que l'utilisateur doit voir.

4.5.2 /api/issue/token : Échanger le code contre un jeton#

Une fois que l'utilisateur a scanné le QR code et entré son PIN, le wallet effectue une requête POST à ce point de terminaison. Son travail consiste à valider le pre-authorized_code et le user_pin (PIN), et s'ils sont valides, à émettre un access token de courte durée.

Créez le fichier src/app/api/issue/token/route.ts avec le contenu suivant :

// 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. Valider le type de subvention if (grant_type !== "urn:ietf:params:oauth:grant-type:pre-authorized_code") { return NextResponse.json( { error: "unsupported_grant_type" }, { status: 400 }, ); } // 2. Valider le code pré-autorisé const authCode = await getAuthorizationCode(code); if (!authCode) { return NextResponse.json( { error: "invalid_grant", error_description: "Invalid or expired code", }, { status: 400 }, ); } // 3. Valider le 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. Générer le jeton d'accès et le c_nonce const accessToken = uuidv4(); const cNonce = uuidv4(); const cNonceExpiresAt = new Date(Date.now() + 5 * 60 * 1000); // 5 minutes // 5. Créer une nouvelle session d'émission const userData = (global as any).userDataStore?.get(code); await createIssuanceSession( uuidv4(), authCode.id, accessToken, cNonce, cNonceExpiresAt, userData, ); // 6. Marquer le code comme utilisé et nettoyer les données temporaires await markAuthorizationCodeAsUsed(code); (global as any).txCodeStore?.delete(code); (global as any).userDataStore?.delete(code); // 7. Retourner la réponse du jeton d'accès return NextResponse.json({ access_token: accessToken, token_type: "Bearer", expires_in: 3600, // 1 heure 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 }); } }

Étapes clés de ce point de terminaison :

  1. Validation du type de subvention : Il s'assure que le wallet utilise le bon type de subvention pre-authorized_code.
  2. Validation du code : Il vérifie que le pre-authorized_code existe dans la base de données, n'est pas expiré et n'a pas été utilisé auparavant.
  3. Validation du PIN : Il compare le user_pin du wallet avec le tx_code que nous avons stocké précédemment pour s'assurer que l'utilisateur a autorisé la transaction.
  4. Génération de jetons : Il crée un access_token sécurisé et un c_nonce (credential nonce), qui est une valeur à usage unique pour prévenir les attaques par rejeu sur le point de terminaison de l'identifiant.
  5. Création de session : Il crée un nouvel enregistrement issuance_sessions dans la base de données, liant l'access token aux données de l'utilisateur.
  6. Marquer le code comme utilisé : Pour empêcher que la même offre ne soit utilisée deux fois, il marque le pre-authorized_code comme utilisé.
  7. Retour du jeton : Il retourne l'access_token et le c_nonce au wallet.

4.5.3 /api/issue/credential : Émettre l'identifiant signé#

C'est le point de terminaison final et le plus important. Le wallet utilise le jeton d'accès qu'il a reçu du point de terminaison /token pour effectuer une requête POST authentifiée à cette route. Le travail de ce point de terminaison est d'effectuer la validation finale, de créer l'identifiant signé cryptographiquement et de le retourner au wallet.

Créez le fichier src/app/api/issue/credential/route.ts avec le contenu suivant :

// 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. Valider le jeton Bearer const authHeader = request.headers.get("authorization"); const accessToken = authHeader?.substring(7); const session = await getIssuanceSessionByToken(accessToken); if (!session) { return NextResponse.json({ error: "invalid_token" }, { status: 401 }); } // 2. Obtenir les données de l'utilisateur de la session const userData = session.user_data; if (!userData) { return NextResponse.json({ error: "missing_user_data" }, { status: 400 }); } // 3. Obtenir la clé d'émetteur active const issuerKey = await getActiveIssuerKey(); if (!issuerKey) { // Dans une application réelle, vous auriez un système de gestion de clés plus robuste. // Pour cette démo, nous pouvons générer une clé à la volée si elle n'existe pas. // Cette partie est omise pour des raisons de brièveté mais se trouve dans le dépôt. return NextResponse.json( { error: "server_error", error_description: "Failed to get issuer key", }, { status: 500 }, ); } // 4. Créer le 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. Stocker l'identifiant émis dans la base de données await createIssuedCredential(/* ... détails de l'identifiant ... */); await updateIssuanceSession(session.id, "credential_issued"); // 6. Retourner l'identifiant signé return NextResponse.json({ format: "jwt_vc", credential: credentialData, c_nonce: uuidv4(), // Un nouveau nonce pour les requêtes ultérieures c_nonce_expires_in: 300, }); } catch (error) { console.error("Credential endpoint error:", error); return NextResponse.json({ error: "server_error" }, { status: 500 }); } }

Étapes clés de ce point de terminaison :

  1. Validation du jeton : Il vérifie la présence d'un jeton Bearer valide dans l'en-tête Authorization et l'utilise pour rechercher la session d'émission active.
  2. Récupération des données utilisateur : Il récupère les données d'attestations de l'utilisateur, qui ont été stockées dans la session lors de la création du jeton.
  3. Chargement de la clé de l'émetteur : Il charge la clé de signature active de l'émetteur depuis la base de données. Dans un scénario réel, cela serait géré par un système de gestion de clés sécurisé.
  4. Création de l'identifiant : Il appelle notre aide createJWTVerifiableCredential de src/lib/crypto.ts pour construire et signer le JWT-VC.
  5. Journalisation de l'émission : Il enregistre un enregistrement de l'identifiant émis dans la base de données à des fins d'audit et de révocation.
  6. Retour de l'identifiant : Il retourne l'identifiant signé au wallet dans une réponse JSON. Le wallet est alors responsable de le stocker en toute sécurité.

5. Exécution de l'Issuer et prochaines étapes#

Vous disposez maintenant d'une implémentation complète et de bout en bout d'un émetteur d'identifiants numériques. Voici comment l'exécuter localement et ce que vous devez considérer pour le faire passer d'une preuve de concept à une application prête pour la production.

5.1 Comment exécuter l'exemple#

  1. Clonez le dépôt :

    git clone https://github.com/corbado/digital-credentials-example.git cd digital-credentials-example
  2. Installez les dépendances :

    npm install
  3. Démarrez la base de données : Assurez-vous que Docker est en cours d'exécution, puis démarrez le conteneur MySQL :

    docker-compose up -d
  4. Configurez l'environnement et lancez le tunnel : C'est l'étape la plus critique pour les tests locaux. Étant donné que votre wallet mobile doit se connecter à votre machine de développement via Internet, vous devez exposer votre serveur local avec une URL HTTPS publique. Nous utiliserons ngrok pour cela.

    a. Démarrez ngrok :

    ngrok http 3000

    b. Copiez l'URL HTTPS depuis la sortie de ngrok (par ex., https://random-string.ngrok.io). c. Créez un fichier .env.local et définissez l'URL :

    NEXT_PUBLIC_BASE_URL=https://<your-ngrok-url>
  5. Exécutez l'application :

    npm run dev

    Ouvrez votre navigateur à l'adresse http://localhost:3000/issue. Vous pouvez maintenant remplir le formulaire, et le QR code généré pointera correctement vers votre URL ngrok publique, permettant à votre wallet mobile de se connecter et de recevoir l'identifiant.

5.2 L'importance de HTTPS et ngrok#

Les protocoles d'identifiants numériques sont conçus avec la sécurité comme priorité absolue. Pour cette raison, les wallets refuseront presque toujours de se connecter à un émetteur via une connexion non sécurisée (http://). L'ensemble du processus repose sur une connexion HTTPS sécurisée, qui est activée par un certificat SSL.

Un service de tunnel comme ngrok résout ces deux problèmes en créant une URL HTTPS publique et sécurisée (avec un certificat SSL valide) qui transfère tout le trafic vers votre serveur de développement local.
Les wallets nécessitent HTTPS et refuseront de se connecter à des points de terminaison non sécurisés (http://). C'est un outil essentiel pour tester tout service web qui doit interagir avec des appareils mobiles ou des webhooks externes.

5.3 Ce qui est hors du champ de ce tutoriel#

Cet exemple est intentionnellement axé sur le flux d'émission de base pour le rendre facile à comprendre. Les sujets suivants sont considérés comme hors du champ d'application :

  • Sécurité prête pour la production : L'émetteur est à des fins éducatives. Un système de production nécessiterait un système de gestion de clés (KMS) sécurisé au lieu de stocker les clés dans une base de données, une gestion robuste des erreurs, une limitation du débit et une journalisation d'audit complète.
  • Révocation des identifiants : Ce guide n'implémente pas de mécanisme de révocation des identifiants émis.
    Bien que le schéma inclue un indicateur revoked pour une utilisation future, aucune logique de révocation n'est fournie ici.
  • Flux de code d'autorisation : Nous nous sommes concentrés exclusivement sur le flux pre-authorized_code. Une implémentation complète du flux authorization_code nécessiterait un écran de consentement de l'utilisateur et une logique OAuth 2.0 plus complexe.
  • Gestion des utilisateurs : Le guide n'inclut aucune authentification ou gestion des utilisateurs pour l'émetteur lui-même. Il est supposé que l'utilisateur est déjà authentifié et autorisé à recevoir un identifiant.

6. Conclusion#

C'est tout ! Avec quelques pages de code, nous avons maintenant un émetteur d'identifiants numériques complet et de bout en bout qui :

  1. Fournit un frontend convivial pour demander des identifiants.
  2. Implémente le flux complet pre-authorized_code d'OpenID4VCI.
  3. Expose tous les points de terminaison de découverte nécessaires à l'interopérabilité des wallets.
  4. Génère et signe un JWT-Verifiable Credential sécurisé et conforme aux normes.

Bien que ce guide fournisse une base solide, un émetteur prêt pour la production nécessiterait des fonctionnalités supplémentaires comme une gestion robuste des clés, un stockage persistant au lieu de stockages en mémoire, la révocation des identifiants et un renforcement complet de la sécurité.
La compatibilité des wallets varie également ; Sphereon Wallet est recommandé pour les tests, mais d'autres wallets pourraient ne pas prendre en charge le flux pré-autorisé tel qu'implémenté ici. Cependant, les éléments de base et le flux d'interaction resteraient les mêmes. En suivant ces modèles, vous pouvez construire un émetteur sécurisé et interopérable pour tout type d'identifiant numérique.

7. Ressources#

Voici quelques-unes des principales ressources, spécifications et outils utilisés ou référencés dans ce tutoriel :

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

Start Free Trial

Share this article


LinkedInTwitterFacebook

Table of Contents