Recettes : comment nous avons construit une boutique en ligne avec connexion, contrôle d’âge et signature par le portefeuille européen
Notre boutique de démo est une seule page web statique – pas de logiciel de boutique, pas de serveur applicatif propre. Les clients s’inscrivent et se connectent avec les données d’identité personnelles (PID) de leur portefeuille européen, passent un contrôle d’âge et signent la confirmation de commande dans CodeB Wallet. Cet article montre comment tout s’assemble, avec le code que nous utilisons, pour que vous puissiez faire de même dans votre boutique.

Ce que fait la boutique de démo
Essayez d’abord – cela prend deux minutes : ouvrir la boutique de démo. Rien n’est vendu, rien n’est payé et rien n’est enregistré sur un serveur. Le déroulement :
- S’inscrire / se connecter avec le portefeuille européen – sans mot de passe, sans e-mail de confirmation.
- La boutique reçoit uniquement le nom du PID – ni la date de naissance, ni l’adresse, ni le numéro du document.
- Un article 18+ demande un contrôle d’âge : le portefeuille répond seulement « plus de 18 ans : oui » (simulé dans la démo).
- Après la commande, le client signe la confirmation de commande dans CodeB Wallet et récupère le PDF signé dans la boutique.
Les ingrédients
La boutique ne parle jamais directement à un portefeuille. Elle utilise simplement OpenID Connect avec la passerelle – le protocole des boutons « Se connecter avec … » – et la passerelle s’occupe de la partie portefeuille (OpenID4VP, vérification de l’émetteur et du portefeuille). Une boutique n’a donc besoin d’aucune bibliothèque de portefeuille.
| Élément | Rôle | Où il tourne |
|---|---|---|
| Page de la boutique | HTML, CSS et environ 400 lignes de JavaScript simple – sans framework | N’importe quel serveur web (ici : myeudi.app) |
| CodeB EUDI Gateway | Fournisseur OpenID Connect devant les portefeuilles européens : interroge le portefeuille, vérifie la réponse, émet les jetons | phone.codeb.io |
| CodeB Wallet | Contient le PID, ne partage que ce que vous acceptez, signe des PDF | App Android ; pour la connexion aussi le portefeuille web dans le navigateur |
| Demandes de signature | Transmet un PDF au portefeuille et reçoit le PDF signé | phone.codeb.io |
Étape 1 – obtenir un client OpenID Connect
La boutique est un client public : une page web ne peut pas garder de secret, elle utilise donc PKCE au lieu d’un secret client. Pour le client, il nous faut :
l’identifiant client (p. ex. my-shop), la ou les URI de redirection – la page où le client revient après l’étape du portefeuille – et les origines du navigateur (p. ex. https://shop.example) autorisées à appeler les points d’accès token, userinfo et signature depuis JavaScript (CORS).
La configuration que la boutique utilise ensuite :
// Settings of the demo shop (CodeB EUDI Gateway on phone.codeb.io)
const CFG = {
clientId: 'my-shop', // your OpenID Connect client
issuer: 'https://phone.codeb.io',
authorize: 'https://phone.codeb.io/oauth2/v1/authorize',
token: 'https://phone.codeb.io/oidc.ashx?action=token',
userinfo: 'https://phone.codeb.io/oidc.ashx?action=userinfo',
acr: 'urn:codeb:acr:eudi-wallet' // "signed in with an EU wallet"
};Vous voulez un client pour votre boutique ou un système de test ? Écrivez-nous – voir Pour les organisations.
Étape 2 – « Se connecter avec mon portefeuille européen »
Un clic sur le bouton construit une demande d’autorisation OpenID Connect classique avec PKCE et envoie le navigateur vers la passerelle. Là, le client scanne un code QR avec CodeB Wallet, touche Ouvrir CodeB Wallet sur le téléphone ou utilise le portefeuille web. acr_values demande une connexion avec un portefeuille européen.
function b64url(bytes) {
return btoa(String.fromCharCode(...bytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
const rnd = n => b64url(crypto.getRandomValues(new Uint8Array(n)));
async function loginWithWallet() {
const verifier = rnd(32), state = rnd(16), nonce = rnd(16);
const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
sessionStorage.setItem('oidc', JSON.stringify({ verifier, state, nonce }));
const q = new URLSearchParams({
response_type: 'code', client_id: CFG.clientId,
redirect_uri: location.origin + location.pathname,
scope: 'openid profile', state, nonce,
code_challenge: b64url(new Uint8Array(hash)), code_challenge_method: 'S256',
acr_values: CFG.acr, ui_locales: 'en'
});
location.assign(CFG.authorize + '?' + q); // QR code, app button or web wallet: the gateway asks
}Étape 3 – de retour dans la boutique : échanger le code et vérifier l’id_token
La passerelle renvoie le client avec ?code=…&state=…. La boutique échange le code contre les jetons et vérifie l’id_token : émetteur, destinataire, nonce – et acr. Vérifiez acr vous-même : acr_values est un souhait, et la boutique n’accepte qu’une connexion par portefeuille.
L’id_token arrive directement du point d’accès token via TLS ; OpenID Connect permet alors au client de se fier à TLS plutôt qu’à la signature du jeton (OpenID Connect Core, 3.1.3.7). Un serveur qui transmet des id_tokens devrait vérifier la signature avec les clés de jwks_uri dans https://phone.codeb.io/.well-known/openid-configuration.
function claims(jwt) { // payload of the id_token (UTF-8 safe)
const b = atob(jwt.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'));
return JSON.parse(new TextDecoder().decode(Uint8Array.from(b, ch => ch.charCodeAt(0))));
}
async function finishLogin() {
const qs = new URLSearchParams(location.search);
const p = JSON.parse(sessionStorage.getItem('oidc') || 'null');
sessionStorage.removeItem('oidc');
if (qs.get('error')) throw new Error(qs.get('error') + ' ' + (qs.get('error_description') || ''));
if (!p || qs.get('state') !== p.state) throw new Error('state does not match');
// Form-encoded POST without other headers = a CORS "simple" request (no preflight).
const r = await fetch(CFG.token, {
method: 'POST', credentials: 'omit',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code', code: qs.get('code'), client_id: CFG.clientId,
redirect_uri: location.origin + location.pathname, code_verifier: p.verifier
})
});
if (!r.ok) throw new Error('token: HTTP ' + r.status + ' ' + await r.text());
const t = await r.json();
const c = claims(t.id_token);
const audOk = c.aud === CFG.clientId || (Array.isArray(c.aud) && c.aud.includes(CFG.clientId));
if (c.iss !== CFG.issuer || !audOk || c.nonce !== p.nonce) throw new Error('id_token');
if (c.acr !== CFG.acr) throw new Error('not signed in with an EU wallet');
return t; // access_token, id_token, expires_in
}Étape 4 – seulement le nom du PID
La boutique a besoin d’un nom pour la livraison – rien de plus. Userinfo renvoie le prénom et le nom ; c’est exactement ce que le portefeuille a montré au client avant le partage. La date de naissance, l’adresse et le numéro du document n’arrivent jamais à la boutique. L’adresse de livraison est saisie par le client, comme dans toute boutique.
// No Authorization header: the token goes in the form body, so the browser sends no preflight.
const u = await fetch(CFG.userinfo, {
method: 'POST', credentials: 'omit',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'access_token=' + encodeURIComponent(t.access_token)
}).then(r => r.json());
console.log(u.given_name, u.family_name); // the shop asked for nothing elseÉtape 5 – le contrôle d’âge
Pour un article 18+, la boutique pose une seule question : plus de 18 ans ? Le portefeuille répond oui ou non – sans date de naissance, sans nom. Dans la boutique de démo, cette étape est simulée, pour que vous voyiez ce que voit le client et ce que reçoit la boutique. Un vrai contrôle « plus de 18 ans » avec votre propre portefeuille : playground et vérification de l’âge.
Étape 6 – signer la confirmation de commande dans le portefeuille
La boutique crée la confirmation de commande en PDF dans le navigateur et la transmet à la passerelle comme demande de signature. La réponse contient un lien eudi-rqes:// : sur un ordinateur, la boutique l’affiche en code QR, sur le téléphone en bouton. CodeB Wallet télécharge le PDF, montre qui demande, signe – avec le service de signature CodeB ou avec la carte de signature du client – et renvoie le PDF signé. La boutique interroge le statut et propose le PDF signé au téléchargement.
Le jeton d’accès de la connexion autorise la demande : seuls les clients connectés peuvent en créer une. status_token permet à la page de demander le statut sans le jeton d’accès.
const SIGN = 'https://phone.codeb.io/sign-request.ashx';
async function requestSignature(pdf, accessToken) { // pdf: a Blob
const fd = new FormData(); // multipart: also a "simple" request
fd.append('access_token', accessToken);
fd.append('client_id', CFG.clientId);
fd.append('level', 'advanced'); // 'qualified' = signature card only
fd.append('label', 'Order confirmation 4711');
fd.append('return_uri', location.href); // "Back to the website" in the app
fd.append('file', pdf, 'order-4711.pdf');
const r = await fetch(SIGN + '?action=create', { method: 'POST', credentials: 'omit', body: fd });
if (!r.ok) throw new Error('sign-create: HTTP ' + r.status + ' ' + await r.text());
return r.json(); // { id, link: 'eudi-rqes://…', status_token, expires_in }
}
async function waitForSignedPdf(j) {
const q = 'id=' + encodeURIComponent(j.id) + '&t=' + encodeURIComponent(j.status_token);
for (;;) {
await new Promise(ok => setTimeout(ok, 3000));
const s = await fetch(SIGN + '?action=status&' + q, { credentials: 'omit', cache: 'no-store' }).then(r => r.json());
if (s.status === 'signed') return fetch(SIGN + '?action=signed&' + q, { credentials: 'omit' }).then(r => r.blob());
if (['declined', 'expired', 'withdrawn'].includes(s.status)) throw new Error(s.status);
}
}<!-- computer: QR code for the QR scanner in CodeB Wallet; phone: the same link as a button -->
<div id="qr"></div>
<a class="btn" id="open-app">Open CodeB Wallet</a>
<script src="qrcode.js"></script> <!-- any QR encoder; the demo uses a local copy of an MIT-licensed one -->
<script>
const q = qrcode(0, 'M'); q.addData(j.link); q.make();
document.getElementById('qr').innerHTML = q.createSvgTag({ cellSize: 4, margin: 4, scalable: true });
document.getElementById('open-app').href = j.link; // eudi-rqes://?client_id=…&request_uri=…
</script>Étape 7 – une place pour la signature
Une signature rend mieux dans un cadre prévu pour elle. La confirmation de commande contient donc un champ de signature vide – du PDF standard, que tout lecteur PDF affiche. Une demande de signature peut désigner ce champ, et CodeB Wallet signe dedans, avec l’image choisie par le client : la photo du document d’identité, une signature manuscrite ou le nom. En savoir plus : champs de signature.
% An empty signature field (ISO 32000-1, 12.7.4.5): a /Sig widget with a box, no value yet
6 0 obj
<< /Type /Annot /Subtype /Widget /FT /Sig /T (Customer_Signature)
/Rect [60 180 300 260] /P 3 0 R /F 4 >>
endobj
% the catalog lists the field ...
1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R] >> >> endobj
% ... and the page lists the widget
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842]
/Contents 4 0 R /Resources << ... >> /Annots [6 0 R] >> endobjÉtape 8 – en-têtes de sécurité et CORS
La boutique de démo fonctionne avec une Content Security Policy stricte : scripts uniquement depuis son propre site, appels réseau uniquement vers elle-même et phone.codeb.io.
Les appels du navigateur vers phone.codeb.io restent des requêtes CORS « simples » : POST encodés en formulaire ou multipart et GET, sans en-tête Authorization, sans identifiants. Plus de requêtes preflight ni de cookies.
Content-Security-Policy: default-src 'none'; script-src 'self'; style-src 'self';
img-src 'self'; connect-src 'self' https://phone.codeb.io;
base-uri 'self'; form-action 'none'; frame-ancestors 'none'Ce que nous avons appris en chemin
- Affichez la raison quand quelque chose échoue. La démo affiche une ligne technique comme
token: HTTP 400 invalid_grant. Notre premier vrai test a échoué – la ligne a montré la cause en quelques secondes. - N’envoyez pas le jeton d’accès dans un en-tête Authorization depuis le navigateur tant que le serveur ne répond pas aux requêtes preflight : le navigateur bloque l’appel et dit seulement « failed to fetch ».
- Vérifiez acr dans l’id_token. Sinon, toute autre méthode de connexion de la passerelle ouvrirait aussi la boutique.
- Gardez l’état dans sessionStorage, pas dans localStorage. Sur le téléphone, le client quitte la page pour l’app et revient par le lien de retour – la mémoire de la page a disparu, sessionStorage non, et il se termine avec l’onglet.
- Le texte PDF demande du soin : les polices PDF intégrées ne connaissent que le Latin-1 – les lettres maltaises ħ, ġ, ż et ċ ont besoin d’une correspondance ou d’une police incorporée.
De la démo à la production
- Faites l’échange du code sur votre serveur (client confidentiel) et gardez les jetons hors du navigateur.
- Vérifiez les signatures des id_tokens avec les clés de la passerelle (
jwks_uri). - Conservez le PDF signé avec la commande – c’est votre preuve de l’accord du client.
- Utilisez
level=qualifiedlà où une signature électronique qualifiée est exigée (carte de signature), sinonadvanced. - Ne demandez que les données nécessaires – le portefeuille montre chaque élément au client avant le partage.