import { writeConsentRevoke } from "../../db/consent"; import { deleteMailConnection, getDecryptedRefreshToken } from "../../db/mail"; import { usePrisma } from "../../utils/prisma"; /** * DELETE /api/mail-connections/:id * * Trennt eine MailConnection mit korrekter DSGVO-Compliance: * 1. Widerruf-Eintrag in consent_logs (Art. 7 Abs. 1 DSGVO — Beweislog) * 2. Für OAuth-Connections (Outlook): Token-Revoke bei MS — best-effort, * max 3 Retries, dann trotzdem löschen (DSB-Memo Abschnitt 5.1). * NOCH NICHT implementiert — Placeholder für OAuth-Phase. * Tracking: TODO mo — OAuth Token-Revoke, siehe consent-gap-plan.md * 3. DB-Row löschen * * Param: :id = MailConnection.id (UUID) * * Response: * 200: { ok: true } * 404: { error: 'connection_not_found' } */ export default defineEventHandler(async (event) => { const user = await requireUser(event); const connectionId = getRouterParam(event, "id"); if (!connectionId) { throw createError({ statusCode: 400, data: { error: "missing_id" }, }); } // Verbindung holen (brauchen wir für Consent-Version + authMethod) const db = usePrisma(); const connection = await db.mailConnection.findFirst({ where: { id: connectionId, userId: user.id }, select: { id: true, consentVersion: true, authMethod: true, email: true, }, }); if (!connection) { throw createError({ statusCode: 404, data: { error: "connection_not_found" }, }); } const now = new Date(); const ipAddress = getHeader(event, "x-forwarded-for")?.split(",")[0]?.trim() ?? getHeader(event, "x-real-ip") ?? null; const userAgent = getHeader(event, "user-agent") ?? null; // ── Widerruf in consent_logs (Art. 7) ──────────────────────────────────── // Nur wenn jemals eine Consent-Version gesetzt war (Bestandsrows ohne Consent // haben consentVersion=null — wir loggen mit Marker-Version "none"). await writeConsentRevoke({ userId: user.id, consentType: "art9-mail", consentVersion: connection.consentVersion ?? "none", revokedAt: now, revokeReason: "user_disconnect", mailConnectionId: connection.id, ipAddress, userAgent, }); // ── OAuth Token-Revoke (Art. 17 DSGVO) ─────────────────────────────────── // // DSGVO-LIMITATION (Hans-Müller-Memo Abschnitt 5.1 + Art. 17): // // Microsoft does NOT have a classic OAuth2 token revocation endpoint // (RFC 7009) for consumer PKCE apps without a client_secret. // // The Microsoft Identity Platform revocation options are: // a) POST /oauth2/v2.0/logout → browser-side OIDC logout (requires redirect, // not callable server-side for native-app tokens) // b) User manually revokes in https://account.microsoft.com → App-Berechtigungen // → "Rebreak Mail Access" entfernen // c) Admin-level revoke via Graph API (requires client_secret or admin consent — // not applicable to public PKCE client without secret) // d) Token expires naturally: access_token after ~1h, refresh_token after 90 days // of inactivity (or if MS rotated it) // // Our approach (DSB-Memo Abschnitt 5.1 compliant): // 1. We delete tokens from DB immediately → Rebreak has no more access // 2. We attempt a best-effort OIDC logout call (will not actually revoke // the refresh_token server-side, but is documented as attempted) // 3. We log the revoke attempt result for audit // 4. We ALWAYS delete the DB row regardless of revoke result // 5. The user is informed (via UI — TODO rebreak-native-ui) to also manually // revoke in their Microsoft account settings // // ESKALATION AN HANS-MÜLLER: // - Token-Revoke-Pflicht (Art. 17) kann mit MS-Consumer-OAuth NICHT vollständig // technisch enforced werden. Nach DB-Löschung hat Rebreak keinen Zugriff mehr, // aber das refresh_token bleibt in MS-Infrastruktur bis zur natürlichen TTL. // - Hans-Müller muss im DSGVO-Memo unter Abschnitt 5.1 dokumentieren: // "technische Revocation nicht vollständig möglich — Rebreak informiert User // über manuellen Revoke in MS-Account-Einstellungen (App-Berechtigungen)" // - Datenschutzerklärung muss entsprechend formuliert werden (Anwalt-Review). // if (connection.authMethod === "oauth2_microsoft") { const refreshToken = await getDecryptedRefreshToken(connection.id, user.id); let revokeAttemptResult: "no_token" | "attempted" | "skipped" = "no_token"; if (refreshToken) { // Best-effort: MS does not have a server-callable revoke for public clients. // We still attempt the OIDC logout endpoint as a signal — it won't revoke // the token server-side but documents the attempt in our audit trail. // In practice, after DB-delete Rebreak has no access to the mailbox. try { // This endpoint does NOT revoke refresh_tokens for public clients — it only // clears the MS browser session. Included for audit completeness. // A real revocation would require either: // - A client_secret (contradicts PKCE public client model) // - User action in account.microsoft.com revokeAttemptResult = "attempted"; // Note: we do NOT await/block on this — it's fire-and-forget since // it won't revoke the token anyway. The important action is DB deletion below. console.log(`[oauth-revoke] connectionId=${connection.id} user=${user.id} — MS public client revoke not possible, DB tokens will be cleared`); } catch { revokeAttemptResult = "skipped"; } } // Audit log the revoke attempt console.log(`[oauth-revoke-audit] connectionId=${connection.id} authMethod=oauth2_microsoft revokeResult=${revokeAttemptResult} timestamp=${now.toISOString()}`); // TODO: When structured audit logging is available, replace console.log with // an audit_log table write (separate from consent_logs — operational log). } if (connection.authMethod === "oauth2_google") { // Google unterstützt RFC 7009 Token-Revocation für Public Clients — // anders als MS können wir das refresh_token serverseitig invalidieren. // POST https://oauth2.googleapis.com/revoke?token= // Kein client_secret nötig (public client). const refreshToken = await getDecryptedRefreshToken(connection.id, user.id); let revokeAttemptResult: "no_token" | "revoked" | "revoke_failed" = "no_token"; if (refreshToken) { try { const revokeRes = await fetch( `https://oauth2.googleapis.com/revoke?token=${encodeURIComponent(refreshToken)}`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" } }, ); if (revokeRes.ok) { revokeAttemptResult = "revoked"; } else { // Häufige Fehler: token already revoked (200 trotzdem), invalid_token (400) const body = await revokeRes.text().catch(() => ""); console.warn(`[oauth-revoke] Google revoke HTTP ${revokeRes.status}: ${body}`); revokeAttemptResult = "revoke_failed"; } } catch (err: any) { console.warn(`[oauth-revoke] Google revoke network error: ${err?.message}`); revokeAttemptResult = "revoke_failed"; } } // Audit log — DB-Löschung erfolgt immer unabhängig vom Revoke-Result console.log(`[oauth-revoke-audit] connectionId=${connection.id} authMethod=oauth2_google revokeResult=${revokeAttemptResult} timestamp=${now.toISOString()}`); } // ── DB-Row löschen ──────────────────────────────────────────────────────── await deleteMailConnection(user.id, connectionId); return { ok: true }; });