Echtes Google-/PayPal-Login: OAuth 2.0 + PKCE, eigene Kundendatenbank, DSGVO-Selbstbedienung

Kundenkonten sind jetzt genauso echt wie die PayPal-Zahlung: server-geprüftes OAuth 2.0 mit
PKCE für Google und "Log in with PayPal" (functions/_shared/oauth.js, oauth-handlers.js),
neue D1-Tabelle "customers" (bewusst ohne Passwort-Feld), eigene von der Zugangscode-Schranke
getrennte Sitzungs-Logik (customer-auth.js). Echte DSGVO-Rechte direkt im Kontobereich:
Daten herunterladen (Art. 15/20) und Konto unwiderruflich löschen (Art. 17), Bestellungen
bleiben aus gesetzlichen Gründen erhalten. Datenschutzerklärung entsprechend ergänzt.

Ohne echte Google-/PayPal-Zugangsdaten zeigt der Login-Button ehrlich einen
"noch nicht eingerichtet"-Hinweis statt eine Anmeldung vorzutäuschen.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
qcigano
2026-08-04 01:45:55 +02:00
co-authored by Claude Sonnet 5
parent e80aba5ca6
commit bf0d169015
24 changed files with 985 additions and 127 deletions
+141
View File
@@ -0,0 +1,141 @@
/* =====================================================================
functions/_shared/oauth.js — echte OAuth-/OpenID-Connect-Anmeldung für Google und "Log in
with PayPal". Standard-Authorization-Code-Flow MIT PKCE (Proof Key for Code Exchange) — auch
wenn der Code-Austausch server-seitig läuft (also ein "confidential client" ist, der das
Client-Secret kennt), macht PKCE einen abgefangenen Autorisierungs-Code für Angreifer wertlos,
ohne den echten, zufällig erzeugten code_verifier — zusätzliche Verteidigungsebene, siehe
functions/api/auth/<provider>/start.js für die Erzeugung.
WICHTIG: Der Server vertraut NIE einer vom Client mitgeschickten Identität — E-Mail/Name/
Nutzer-ID kommen ausschließlich aus der direkten, server-zu-server-Antwort von Google/PayPal
selbst (holeNutzerprofil()), nachdem WIR den Autorisierungs-Code gegen ein Zugangstoken
getauscht haben. Genau dasselbe Sicherheitsprinzip wie bei der PayPal-Zahlungsprüfung (siehe
functions/_shared/paypal.js). ===================================================================== */
function paypalBasis(env) {
return env.PAYPAL_ENV === "live" ? "https://api-m.paypal.com" : "https://api-m.sandbox.paypal.com";
}
function paypalSigninBasis(env) {
return env.PAYPAL_ENV === "live" ? "https://www.paypal.com" : "https://www.sandbox.paypal.com";
}
const PROVIDERS = {
google: {
authorizeUrl: () => "https://accounts.google.com/o/oauth2/v2/auth",
tokenUrl: () => "https://oauth2.googleapis.com/token",
userinfoUrl: () => "https://www.googleapis.com/oauth2/v3/userinfo",
scope: "openid email profile",
clientIdEnvKey: "GOOGLE_CLIENT_ID",
clientSecretEnvKey: "GOOGLE_CLIENT_SECRET",
// Google akzeptiert den Token-Exchange als klassisches Formular (application/x-www-form-urlencoded).
tokenAuthStyle: "body",
parseUserinfo: (data) => ({ providerUserId: data.sub, email: data.email, name: data.name || null }),
},
paypal: {
authorizeUrl: (env) => `${paypalSigninBasis(env)}/signin/authorize`,
tokenUrl: (env) => `${paypalBasis(env)}/v1/oauth2/token`,
userinfoUrl: (env) => `${paypalBasis(env)}/v1/identity/openidconnect/userinfo/?schema=openid`,
scope: "openid email profile",
// "Log in with PayPal" nutzt bewusst DIESELBEN App-Zugangsdaten wie die PayPal-Zahlungs-
// anbindung (functions/_shared/paypal.js) — VanVan muss in ihrer einen PayPal-App im
// Entwickler-Dashboard nur zusätzlich die Funktion "Log in with PayPal" aktivieren, statt
// eine zweite App anzulegen (siehe Vault-Doku).
clientIdEnvKey: "PAYPAL_CLIENT_ID",
clientSecretEnvKey: "PAYPAL_CLIENT_SECRET",
tokenAuthStyle: "basic",
parseUserinfo: (data) => ({ providerUserId: data.user_id || data.payer_id, email: data.email, name: data.name || null }),
},
};
export function provider(name) {
const p = PROVIDERS[name];
if (!p) throw new Error(`Unbekannter OAuth-Anbieter: ${name}`);
return p;
}
export function istProviderKonfiguriert(env, name) {
const p = provider(name);
return !!(env[p.clientIdEnvKey] && env[p.clientSecretEnvKey]);
}
/** Baut die Weiterleitungs-URL zum Anbieter — state (CSRF-Schutz) und code_challenge (PKCE)
* wurden vorher vom Aufrufer erzeugt und in signierten Cookies zwischengespeichert. */
export function baueAutorisierungsUrl(env, name, { redirectUri, state, codeChallenge }) {
const p = provider(name);
const clientId = env[p.clientIdEnvKey];
const params = new URLSearchParams({
client_id: clientId,
response_type: "code",
scope: p.scope,
redirect_uri: redirectUri,
state,
code_challenge: codeChallenge,
code_challenge_method: "S256",
});
if (name === "google") params.set("prompt", "select_account");
return `${p.authorizeUrl(env)}?${params.toString()}`;
}
/** Tauscht den Autorisierungs-Code server-seitig gegen ein Zugangstoken — das Client-Secret
* verlässt den Server dabei nie, es fließt nur in diesen einen serverseitigen Aufruf ein. */
export async function tauscheCodeGegenToken(env, name, { code, redirectUri, codeVerifier }) {
const p = provider(name);
const clientId = env[p.clientIdEnvKey];
const clientSecret = env[p.clientSecretEnvKey];
const body = new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: redirectUri,
code_verifier: codeVerifier,
});
const headers = { "Content-Type": "application/x-www-form-urlencoded" };
if (p.tokenAuthStyle === "basic") {
headers.Authorization = `Basic ${btoa(`${clientId}:${clientSecret}`)}`;
} else {
body.set("client_id", clientId);
body.set("client_secret", clientSecret);
}
const res = await fetch(p.tokenUrl(env), { method: "POST", headers, body: body.toString() });
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`Token-Austausch bei ${name} fehlgeschlagen (Status ${res.status}): ${text}`);
}
const data = await res.json();
if (!data.access_token) throw new Error(`${name} hat kein Zugangstoken geliefert.`);
return data.access_token;
}
/** Holt Name/E-Mail/Nutzer-ID DIREKT vom Anbieter (server-zu-server, mit dem eben erhaltenen
* Zugangstoken) — das ist die einzige Quelle, der wir für die Identität vertrauen. */
export async function holeNutzerprofil(env, name, accessToken) {
const p = provider(name);
const res = await fetch(p.userinfoUrl(env), { headers: { Authorization: `Bearer ${accessToken}` } });
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`Profilabruf bei ${name} fehlgeschlagen (Status ${res.status}): ${text}`);
}
const data = await res.json();
const profil = p.parseUserinfo(data);
if (!profil.providerUserId || !profil.email) {
throw new Error(`${name} hat kein vollständiges Profil geliefert (E-Mail/ID fehlt).`);
}
return profil;
}
// ── PKCE-Hilfsfunktionen ──
function b64url(bytes) {
let bin = "";
bytes.forEach((b) => (bin += String.fromCharCode(b)));
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
export function erzeugeZufallswert(laenge = 32) {
const bytes = new Uint8Array(laenge);
crypto.getRandomValues(bytes);
return b64url(bytes);
}
export async function codeChallengeFuer(codeVerifier) {
const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(codeVerifier));
return b64url(new Uint8Array(hash));
}