/* ===================================================================== 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//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)); }