Kochbuch: So haben wir einen Webshop mit EU-Wallet-Anmeldung, Altersprüfung und Unterschrift gebaut
Unser Demo-Shop ist eine einzige statische Webseite – keine Shop-Software, kein eigenes Backend. Kundinnen und Kunden registrieren sich und melden sich mit dem Personalausweis-Datensatz (PID) in ihrer EU-Wallet an, bestehen eine Altersprüfung und unterschreiben die Bestellbestätigung in CodeB Wallet. Dieser Artikel zeigt, wie alles zusammenspielt – mit dem Code, den wir verwenden –, damit Sie dasselbe in Ihren eigenen Shop einbauen können.

Was der Demo-Shop tut
Probieren Sie es zuerst aus – es dauert zwei Minuten: Demo-Shop öffnen. Nichts wird verkauft, nichts bezahlt und nichts auf einem Server gespeichert. Der Ablauf:
- Registrieren / anmelden mit der EU-Wallet – ohne Passwort, ohne Bestätigungs-E-Mail.
- Der Shop erhält aus der PID nur den Namen – nicht das Geburtsdatum, die Adresse oder die Ausweisnummer.
- Ein Artikel ab 18 braucht eine Altersprüfung: Die Wallet antwortet nur „über 18: ja“ (im Demo simuliert).
- Nach der Bestellung unterschreibt der Kunde die Bestellbestätigung in CodeB Wallet und bekommt das unterschriebene PDF im Shop zurück.
Die Zutaten
Der Shop spricht nie direkt mit einer Wallet. Er nutzt einfaches OpenID Connect zum Gateway – das Protokoll hinter den bekannten „Anmelden mit …“-Schaltflächen –, und das Gateway erledigt den Wallet-Teil (OpenID4VP, Prüfung von Aussteller und Wallet). Darum braucht ein Shop überhaupt keine Wallet-Bibliothek.
| Teil | Was er tut | Wo er läuft |
|---|---|---|
| Shop-Seite | HTML, CSS und rund 400 Zeilen einfaches JavaScript – kein Framework | Beliebiger Webserver (hier: myeudi.app) |
| CodeB EUDI Gateway | OpenID-Connect-Anbieter vor den EU-Wallets: fragt die Wallet, prüft die Antwort, stellt die Tokens aus | phone.codeb.io |
| CodeB Wallet | Hält die PID, teilt nur, was Sie freigeben, unterschreibt PDFs | Android-App; für die Anmeldung auch die Web-Wallet im Browser |
| Signaturanfragen | Übergibt ein PDF an die Wallet und erhält das unterschriebene PDF | phone.codeb.io |
Schritt 1 – einen OpenID-Connect-Client bekommen
Der Shop ist ein öffentlicher Client: Eine Webseite kann kein Geheimnis bewahren, deshalb nutzt sie PKCE statt eines Client-Secrets. Für den Client brauchen wir von Ihnen:
Client-ID (z. B. my-shop), die Redirect-URI(s) – die Seite, auf die der Kunde nach dem Wallet-Schritt zurückkommt – und die Browser-Origins (z. B. https://shop.example), die Token-, Userinfo- und Signatur-Endpunkte aus JavaScript aufrufen dürfen (CORS).
Die Einstellungen, die der Shop dann verwendet:
// 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"
};Sie möchten einen Client für Ihren Shop oder ein Testsystem? Schreiben Sie uns – siehe Für Organisationen.
Schritt 2 – „Mit meiner EU-Wallet anmelden“
Ein Klick auf die Schaltfläche baut eine normale OpenID-Connect-Autorisierungsanfrage mit PKCE und schickt den Browser zum Gateway. Dort scannt der Kunde einen QR-Code mit CodeB Wallet, tippt auf dem Handy auf CodeB Wallet öffnen oder nutzt die Web-Wallet. acr_values verlangt eine Anmeldung mit einer EU-Wallet.
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
}Schritt 3 – zurück im Shop: Code einlösen und id_token prüfen
Das Gateway schickt den Kunden mit ?code=…&state=… zurück. Der Shop tauscht den Code gegen die Tokens und prüft das id_token: Aussteller, Empfänger, Nonce – und acr. Prüfen Sie acr selbst: acr_values ist ein Wunsch, und der Shop akzeptiert nur eine Wallet-Anmeldung.
Das id_token kommt direkt vom Token-Endpunkt über TLS; OpenID Connect erlaubt dem Client deshalb, sich auf TLS statt auf die Signatur des Tokens zu verlassen (OpenID Connect Core, 3.1.3.7). Ein Backend, das id_tokens weitergibt, sollte die Signatur mit den Schlüsseln aus jwks_uri in https://phone.codeb.io/.well-known/openid-configuration prüfen.
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
}Schritt 4 – nur der Name aus der PID
Der Shop braucht einen Namen für die Lieferung – mehr nicht. Userinfo liefert Vor- und Nachname; genau das hat die Wallet dem Kunden vor dem Teilen gezeigt. Geburtsdatum, Adresse und Ausweisnummer erreichen den Shop nie. Die Lieferadresse tippt der Kunde selbst ein, wie in jedem Shop.
// 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 elseSchritt 5 – die Altersprüfung
Für einen Artikel ab 18 stellt der Shop genau eine Frage: über 18? Die Wallet antwortet mit ja oder nein – ohne Geburtsdatum, ohne Namen. Im Demo-Shop ist dieser Schritt simuliert, damit Sie sehen, was der Kunde sieht und was der Shop erhält. Eine echte Ü18-Prüfung mit Ihrer eigenen Wallet: Playground und Altersprüfung.
Schritt 6 – die Bestellbestätigung in der Wallet unterschreiben
Der Shop erzeugt die Bestellbestätigung als PDF im Browser und übergibt sie dem Gateway als Signaturanfrage. Die Antwort enthält einen eudi-rqes://-Link: Am Computer zeigt der Shop ihn als QR-Code, auf dem Handy als Schaltfläche. CodeB Wallet lädt das PDF, zeigt, wer fragt, unterschreibt – mit dem CodeB-Signaturdienst oder mit der Signaturkarte des Kunden – und schickt das unterschriebene PDF zurück. Der Shop fragt den Status ab und bietet das unterschriebene PDF zum Download an.
Das Access-Token aus der Anmeldung berechtigt die Anfrage, also können nur angemeldete Kunden eine erzeugen. Mit status_token fragt die Seite den Status ohne Access-Token ab.
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>Schritt 7 – ein Platz für die Unterschrift
Eine Unterschrift sieht am besten in einem Feld aus, das für sie gedacht ist. Die Bestellbestätigung hat deshalb ein leeres Signaturfeld – Standard-PDF, das jeder PDF-Betrachter anzeigt. Eine Signaturanfrage kann das Feld benennen, und CodeB Wallet unterschreibt hinein, mit dem Bild nach Wahl des Kunden: dem Foto aus dem Ausweisdokument, einer handschriftlichen Unterschrift oder dem Namen. Mehr: Signaturfelder.
% 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] >> endobjSchritt 8 – Sicherheits-Header und CORS
Der Demo-Shop läuft mit einer strengen Content Security Policy: Skripte nur von der eigenen Website, Netzwerkaufrufe nur an sich selbst und an phone.codeb.io.
Browser-Aufrufe an phone.codeb.io bleiben CORS-„simple requests“: formularkodierte oder Multipart-POSTs und GETs, kein Authorization-Header, keine Credentials. Damit entfallen Preflight-Anfragen und Cookies ganz.
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'Was wir unterwegs gelernt haben
- Zeigen Sie den Grund, wenn etwas schiefgeht. Der Demo-Shop zeigt eine technische Zeile wie
token: HTTP 400 invalid_grant. Unser erster echter Test schlug fehl – die Zeile zeigte die Ursache in Sekunden. - Schicken Sie das Access-Token aus dem Browser nicht im Authorization-Header, solange der Server keine Preflight-Anfragen beantwortet: Der Browser blockiert den Aufruf und meldet nur „failed to fetch“.
- Prüfen Sie acr im id_token. Sonst würde auch jede andere Anmeldeart des Gateways den Shop öffnen.
- Zustand in sessionStorage halten, nicht in localStorage. Auf dem Handy verlässt der Kunde die Seite für die App und kommt über den Rücksprung-Link zurück – der Seitenspeicher ist dann weg, sessionStorage nicht, und es endet mit dem Tab.
- PDF-Text braucht Sorgfalt: Die eingebauten PDF-Schriften kennen nur Latin-1 – maltesische ħ, ġ, ż und ċ brauchen eine Zuordnung oder eine eingebettete Schrift.
Vom Demo zur Produktion
- Lösen Sie den Code auf Ihrem Server ein (vertraulicher Client) und halten Sie Tokens aus dem Browser heraus.
- Prüfen Sie id_token-Signaturen mit den Schlüsseln des Gateways (
jwks_uri). - Speichern Sie das unterschriebene PDF mit der Bestellung – es ist Ihr Nachweis, dass der Kunde zugestimmt hat.
- Nutzen Sie
level=qualified, wo eine qualifizierte elektronische Signatur nötig ist (Signaturkarte), sonstadvanced. - Fragen Sie nur die Daten ab, die Sie brauchen – die Wallet zeigt dem Kunden jedes Datum vor dem Teilen.