/* ===================================================================== functions/_shared/oauth-handlers.js — gemeinsame Start-/Callback-Logik für alle OAuth- Anbieter (Google, PayPal), von den dünnen Routen unter functions/api/auth//start.js bzw. callback.js aufgerufen. So gibt es die sicherheitskritische Logik (State-Prüfung, PKCE, Sitzungserstellung) nur EINMAL, nicht einmal pro Anbieter dupliziert. ===================================================================== */ import { erzeugeZufallswert, codeChallengeFuer, baueAutorisierungsUrl, istProviderKonfiguriert, tauscheCodeGegenToken, holeNutzerprofil, } from "./oauth.js"; import { signCustomerSession, customerCookieHeader, CUSTOMER_SESSION_TAGE } from "./customer-auth.js"; const STATE_COOKIE = "vandiy_oauth_state"; const VERIFIER_COOKIE = "vandiy_oauth_verifier"; const LANG_COOKIE = "vandiy_oauth_lang"; const OAUTH_TEMP_MAXAGE = 600; // 10 Minuten — reicht locker für den Login-Vorgang, hält aber die // Angriffsfläche klein, falls die Cookies je "hängen bleiben". const ERLAUBTE_SPRACHEN = ["", "en", "ch", "fr"]; // "" = Deutsch (kein Präfix) function getCookieRaw(request, name) { const header = request.headers.get("Cookie") || ""; const m = header.match(new RegExp(`(?:^|;\\s*)${name}=([^;]+)`)); return m ? decodeURIComponent(m[1]) : null; } function langPrefix(lang) { return ERLAUBTE_SPRACHEN.includes(lang) ? lang : ""; } /** GET /api/auth/:provider/start — leitet zum Anbieter weiter, nachdem State + PKCE-Verifier in * kurzlebigen, signierten Cookies zwischengespeichert wurden. */ export async function starteOAuth(context, providerName) { const { request, env } = context; const url = new URL(request.url); const lang = langPrefix(url.searchParams.get("lang") || ""); if (!istProviderKonfiguriert(env, providerName)) { return Response.redirect(`${url.origin}/${lang ? lang + "/" : ""}konto/?login_error=${providerName}_unconfigured`, 302); } const state = erzeugeZufallswert(24); const codeVerifier = erzeugeZufallswert(48); const codeChallenge = await codeChallengeFuer(codeVerifier); const redirectUri = `${url.origin}/api/auth/${providerName}/callback`; const authUrl = baueAutorisierungsUrl(env, providerName, { redirectUri, state, codeChallenge }); const cookiePath = `/api/auth/${providerName}`; const headers = new Headers({ Location: authUrl }); headers.append("Set-Cookie", `${STATE_COOKIE}=${state}; Path=${cookiePath}; Max-Age=${OAUTH_TEMP_MAXAGE}; HttpOnly; Secure; SameSite=Lax`); headers.append("Set-Cookie", `${VERIFIER_COOKIE}=${codeVerifier}; Path=${cookiePath}; Max-Age=${OAUTH_TEMP_MAXAGE}; HttpOnly; Secure; SameSite=Lax`); headers.append("Set-Cookie", `${LANG_COOKIE}=${lang}; Path=${cookiePath}; Max-Age=${OAUTH_TEMP_MAXAGE}; HttpOnly; Secure; SameSite=Lax`); return new Response(null, { status: 302, headers }); } /** GET /api/auth/:provider/callback — prüft State (CSRF-Schutz), tauscht den Code gegen ein * Zugangstoken, holt das echte Profil direkt vom Anbieter, legt bei Bedarf einen neuen * Kunden-Datensatz an (oder aktualisiert den bestehenden) und setzt die echte Sitzung. */ export async function verarbeiteOAuthCallback(context, providerName) { const { request, env } = context; const url = new URL(request.url); const code = url.searchParams.get("code"); const state = url.searchParams.get("state"); const errorParam = url.searchParams.get("error"); const lang = langPrefix(getCookieRaw(request, LANG_COOKIE) || ""); const kontoPfad = `${url.origin}/${lang ? lang + "/" : ""}konto/`; const cookiePath = `/api/auth/${providerName}`; function loescheTempCookies(headers) { headers.append("Set-Cookie", `${STATE_COOKIE}=; Path=${cookiePath}; Max-Age=0`); headers.append("Set-Cookie", `${VERIFIER_COOKIE}=; Path=${cookiePath}; Max-Age=0`); headers.append("Set-Cookie", `${LANG_COOKIE}=; Path=${cookiePath}; Max-Age=0`); } function fehlerRedirect(grund) { const headers = new Headers({ Location: `${kontoPfad}?login_error=${grund}` }); loescheTempCookies(headers); return new Response(null, { status: 302, headers }); } // Nutzer:in hat bei Google/PayPal abgebrochen/abgelehnt — kein Fehler, nur kein Login. if (errorParam) return fehlerRedirect(`${providerName}_denied`); if (!code || !state) return fehlerRedirect(`${providerName}_invalid`); // ── CSRF-Schutz: der State aus der Weiterleitungs-URL MUSS exakt zum eigenen, kurz zuvor // gesetzten Cookie passen — sonst könnte jemand einen fremden Autorisierungs-Code // "unterschieben" (klassischer OAuth-CSRF-Angriff). ── const stateCookie = getCookieRaw(request, STATE_COOKIE); const verifierCookie = getCookieRaw(request, VERIFIER_COOKIE); if (!stateCookie || stateCookie !== state || !verifierCookie) { return fehlerRedirect(`${providerName}_state_mismatch`); } if (!env.DB || !env.CUSTOMER_SESSION_SECRET) { return fehlerRedirect(`${providerName}_backend_unconfigured`); } try { const redirectUri = `${url.origin}/api/auth/${providerName}/callback`; const accessToken = await tauscheCodeGegenToken(env, providerName, { code, redirectUri, codeVerifier: verifierCookie }); // Einzige Quelle für die Identität: die direkte, server-zu-server-Antwort vom Anbieter. const profil = await holeNutzerprofil(env, providerName, accessToken); const now = new Date().toISOString(); const bestehend = await env.DB.prepare( `SELECT id FROM customers WHERE provider = ? AND provider_user_id = ?` ).bind(providerName, profil.providerUserId).first(); let customerId; if (bestehend) { customerId = bestehend.id; await env.DB.prepare(`UPDATE customers SET email = ?, name = ?, updated_at = ?, last_login_at = ? WHERE id = ?`) .bind(profil.email, profil.name, now, now, customerId) .run(); } else { const insert = await env.DB.prepare( `INSERT INTO customers (created_at, updated_at, last_login_at, provider, provider_user_id, email, name) VALUES (?,?,?,?,?,?,?)` ) .bind(now, now, now, providerName, profil.providerUserId, profil.email, profil.name) .run(); customerId = insert.meta.last_row_id; } const sessionToken = await signCustomerSession(customerId, env.CUSTOMER_SESSION_SECRET); const headers = new Headers({ Location: `${url.origin}/${lang ? lang + "/" : ""}konto/angemeldet/` }); headers.append("Set-Cookie", customerCookieHeader(sessionToken, CUSTOMER_SESSION_TAGE * 24 * 60 * 60)); loescheTempCookies(headers); return new Response(null, { status: 302, headers }); } catch (err) { return fehlerRedirect(`${providerName}_failed`); } }