From fc69a14f25b04db3e1aa7a358127c30947e1f3b7 Mon Sep 17 00:00:00 2001 From: chahinebrini Date: Wed, 13 May 2026 21:04:14 +0200 Subject: [PATCH] =?UTF-8?q?feat(mail):=20outlook=20oauth=20=E2=80=94=20ful?= =?UTF-8?q?l=20end-to-end=20(backend=20+=20daemon=20+=20frontend)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Microsoft hat App-Passwords für consumer-Outlook im September 2024 abgeschaltet. Diese Welle bringt OAuth2/XOAUTH2-Support als zweiten AuthMethod-Pfad — Gmail/ iCloud/GMX/Yahoo bleiben unangetastet auf App-Password. Backend (rebreak-backend): - POST /api/mail/oauth/microsoft/init: PKCE-Flow-Start, generiert code_verifier + Authorization-URL, persistiert pending state mit TTL - POST /api/mail/oauth/microsoft/callback: Token-Exchange (PKCE, kein client_secret weil Public Client), id_token-Decode für Email, MailConnection upsert mit auth_method='oauth2_microsoft' + encrypted Tokens - Token-Refresh-Util backend/server/utils/ms-oauth.ts + DB-Function refreshAndSaveTokens(connectionId, clientId) mit optimistic-concurrency- Race-Condition-Schutz (UPDATE WHERE oauth_token_expiry = , bei affected_rows=0 → frischen Wert lesen statt nochmal refreshen sonst invalid_grant via Token-Rotation) - Neue Tabelle oauth_pending_states (TTL via createdAt + Cleanup-Job-TODO) - [id].delete.ts: echter OAuth-Disconnect — DB-Token-Löschung + Audit-Log (MS hat keinen Drittanbieter-Revoke-Endpoint, daher User-Information-Pflicht per Frontend-Modal, siehe DSB-Memo Section 5.1) - Consent-Gate auch in scan.post.ts + scan-internal.post.ts (Cron-Trigger war ohne Consent-Check = DSGVO-Lücke, jetzt geschlossen mit skippedNoConsent-Field in Response) IDLE-Daemon (backend/imap-idle/index.mjs, mo): - XOAUTH2-Auth-Branch via getCredentialsForConnection() — wenn auth_method='oauth2_microsoft', Token-Expiry-Check (<5min remaining → proaktiver Refresh), sonst decrypted accessToken zu ImapFlow - AUTHENTICATIONFAILED-Recovery: bis 3× reaktiv refresh + reconnect, danach last_connect_error='auth_revoked' (kein Endlos-Loop) - IDLE_RENEW_INTERVAL_MS = 10min — passt für MS 29min-Timeout (gleich wie Gmail/iCloud) - Consent-Pause: Connections mit consent_at=null laufen IDLE weiter (für exists-Event-Wiederaufnahme), aber triggerScan() ist deaktiviert bis consent erteilt - start-idle-staging.sh: MS_OAUTH_CLIENT_ID explizit weiterleiten in den inneren bash -c-Block (war Infisical-Var, ging aber durch strict-mode verloren) Frontend (rebreak-native-ui): - Outlook-Tile re-aktiviert (war disabled mit "Kommt bald" seit Sept-2024- Awareness), authMethod-Discriminator löst statt Email+Pw-Form den OAuth-Flow aus - ConnectMailSheet: neuer view-State 'oauth_warning' (Outing-Effekt-Hinweis per Hans-Müller-Memo Section 6.1) + 'oauth_pending' (Browser-Step-Spinner) - Deep-Link-Handler app/auth/mail-oauth-callback.tsx — auto-registriert durch expo-router-File-Routing, kein Native-Rebuild (scheme 'rebreak' schon im app.config.ts) - mailConnectDraft-Store: pendingOAuthConnectionId für Title-Edit-Sheet direkt nach Connect - MailAccountCard: Password-Row hidden für OAuth-Connections, Post-Disconnect- Modal mit MS-Account-Anleitung (DSB-konform — kompensiert fehlenden Drittanbieter-Revoke-Endpoint mit User-Information) Hans-Müller-DSB-Memo (mail-outlook-oauth-dsgvo-review.md): - Section 4.1 Datenschutzerklärung-Textbaustein: "Wir widerrufen den Token aktiv bei Microsoft"-Satz raus (war faktisch falsch — MS hat keinen Drittanbieter-Revoke). Neuer Wortlaut: DB-Löschung + User-Anleitung account.microsoft.com → Sicherheit → App-Berechtigungen - Section 4.1: User.Read-Scope offen dokumentiert mit Datenminimierungs- Klausel (Scope breiter, wir nutzen NUR Display-Name + Email-Claim) - Section 5.1: ehrliche Doku dass MS keinen RFC-7009-Revoke hat - Section 9 Anwalts-Themen: neue Frage 5 zur Art. 17-Erfüllung trotz fehlendem MS-Revoke Architektur-Eigenschaften: - Generisches AuthMethod-Framework — Gmail/iCloud/Yahoo können später als reine Config-Erweiterung OAuth bekommen, kein Refactor nötig - Token-Encryption via bestehendes crypto.ts (AES-256-GCM, Key aus Infisical) - Consent-Gate konsistent: ConnectMailSheet-Consent-Step VOR Provider- Auswahl (Frontend), backend-Endpoint 412 wenn consent fehlt, Daemon + Scan-Endpoints pausieren bei consent_at=null Open follow-ups: - oauth_pending_states-Cleanup-Cron für abgelaufene Entries (TODO im Backend-Code dokumentiert) - Anwalts-Klärung Hans-Müller Section 9 (DPA-Anspruch ohne MS-Lizenz + Art. 17 mit User-Information statt Revoke-Endpoint) - TIA (Transfer Impact Assessment) für MS-Sub-AV — Hans-Müller-Draft-Aufgabe - Outlook-Tile-Wieder-Aktivierung ist live, aber Phase-1-Production-Test steht aus (User Test auf iPhone nach Pipeline-Deploy) Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/rebreak-native/app/(app)/mail.tsx | 22 +- .../app/auth/mail-oauth-callback.tsx | 33 ++ .../components/mail/ConnectMailSheet.tsx | 234 ++++++++- .../components/mail/MailAccountCard.tsx | 156 +++++- apps/rebreak-native/locales/de.json | 14 + apps/rebreak-native/locales/en.json | 14 + .../rebreak-native/stores/mailConnectDraft.ts | 12 +- .../docs/mail-outlook-oauth-dsgvo-review.md | 56 ++- backend/imap-idle/index.mjs | 452 +++++++++++++++--- backend/imap-idle/start-idle-staging.sh | 3 +- backend/nitro.config.ts | 7 + .../migration.sql | 26 + backend/prisma/schema.prisma | 22 + .../api/mail-connections/[id].delete.ts | 76 ++- .../api/mail/oauth/microsoft/callback.post.ts | 178 +++++++ .../api/mail/oauth/microsoft/init.post.ts | 71 +++ backend/server/api/mail/scan-internal.post.ts | 21 +- backend/server/api/mail/scan.post.ts | 14 +- backend/server/db/mail.ts | 236 +++++++++ backend/server/utils/ms-oauth.ts | 180 +++++++ 20 files changed, 1709 insertions(+), 118 deletions(-) create mode 100644 apps/rebreak-native/app/auth/mail-oauth-callback.tsx create mode 100644 backend/prisma/migrations/20260513_oauth_pending_states/migration.sql create mode 100644 backend/server/api/mail/oauth/microsoft/callback.post.ts create mode 100644 backend/server/api/mail/oauth/microsoft/init.post.ts create mode 100644 backend/server/utils/ms-oauth.ts diff --git a/apps/rebreak-native/app/(app)/mail.tsx b/apps/rebreak-native/app/(app)/mail.tsx index ba8db1b..8f933ea 100644 --- a/apps/rebreak-native/app/(app)/mail.tsx +++ b/apps/rebreak-native/app/(app)/mail.tsx @@ -18,12 +18,14 @@ import { MailActivityLog } from '../../components/mail/MailActivityLog'; import { MailBlockedByDayChart } from '../../components/mail/MailBlockedByDayChart'; import { MailDistributionChart } from '../../components/mail/MailDistributionChart'; import { ConnectMailSheet } from '../../components/mail/ConnectMailSheet'; +import { EditMailTitleSheet } from '../../components/mail/EditMailTitleSheet'; import { SuccessAlert } from '../../components/SuccessAlert'; import { useMailStatus } from '../../hooks/useMailStatus'; import { useMailDisconnect } from '../../hooks/useMailDisconnect'; import { useMailStats } from '../../hooks/useMailStats'; import { useUserPlan } from '../../hooks/useUserPlan'; import { useColors } from '../../lib/theme'; +import { useMailConnectDraft } from '../../stores/mailConnectDraft'; const PLAN_LABEL: Record = { free: 'Free', pro: 'Pro', legend: 'Legend' }; @@ -97,6 +99,9 @@ export default function MailScreen() { const [disconnectingId, setDisconnectingId] = useState(null); const [expandedAccount, setExpandedAccount] = useState(null); const [activityLogExpanded, setActivityLogExpanded] = useState(false); + const [oauthTitleSheetConnectionId, setOauthTitleSheetConnectionId] = useState(null); + + const { pendingOAuthConnectionId, setPendingOAuthConnectionId } = useMailConnectDraft(); const nextScanAt = accounts @@ -129,8 +134,13 @@ export default function MailScreen() { } function handleConnectSuccess() { - setSuccessVisible(true); refresh(); + if (pendingOAuthConnectionId) { + setOauthTitleSheetConnectionId(pendingOAuthConnectionId); + setPendingOAuthConnectionId(null); + } else { + setSuccessVisible(true); + } } function toggleAccount(id: string) { @@ -308,6 +318,16 @@ export default function MailScreen() { onSuccess={handleConnectSuccess} /> + {oauthTitleSheetConnectionId && ( + { setOauthTitleSheetConnectionId(null); setSuccessVisible(true); }} + onSuccess={() => { setOauthTitleSheetConnectionId(null); setSuccessVisible(true); refresh(); }} + /> + )} + { + const timer = setTimeout(() => { + router.replace('/(app)' as never); + }, 80); + return () => clearTimeout(timer); + }, []); + + return ( + + + + ); +} diff --git a/apps/rebreak-native/components/mail/ConnectMailSheet.tsx b/apps/rebreak-native/components/mail/ConnectMailSheet.tsx index 640109f..98729ae 100644 --- a/apps/rebreak-native/components/mail/ConnectMailSheet.tsx +++ b/apps/rebreak-native/components/mail/ConnectMailSheet.tsx @@ -8,6 +8,7 @@ import { TouchableOpacity, View, } from 'react-native'; +import * as WebBrowser from 'expo-web-browser'; import { Ionicons } from '@expo/vector-icons'; import { useTranslation } from 'react-i18next'; import { useMailConnect, detectProvider, type MailProvider } from '../../hooks/useMailConnect'; @@ -36,6 +37,7 @@ type ProviderConfig = { guideUrl: string; disabled?: boolean; disabledLabelKey?: string; + authMethod?: 'imap' | 'oauth_microsoft'; }; const PROVIDERS: ProviderConfig[] = [ @@ -62,8 +64,7 @@ const PROVIDERS: ProviderConfig[] = [ color: '#0078D4', guideKey: 'mail.app_password_guide_outlook', guideUrl: 'https://account.microsoft.com/security', - disabled: true, - disabledLabelKey: 'mail.provider_outlook_disabled_badge', + authMethod: 'oauth_microsoft', }, { id: 'yahoo', @@ -110,11 +111,13 @@ export function ConnectMailSheet({ visible, onClose, onSuccess }: Props) { selectedProvider, email, title, + pendingOAuthConnectionId, setView, setConsentGiven, setSelectedProvider, setEmail, setTitle, + setPendingOAuthConnectionId, reset: resetDraft, } = useMailConnectDraft(); @@ -122,6 +125,8 @@ export function ConnectMailSheet({ visible, onClose, onSuccess }: Props) { const [passwordVisible, setPasswordVisible] = useState(false); const [formError, setFormError] = useState(null); const [fieldsComplete, setFieldsComplete] = useState(false); + const [oauthRunning, setOauthRunning] = useState(false); + const [oauthError, setOauthError] = useState(null); function handleClose() { resetDraft(); @@ -129,6 +134,8 @@ export function ConnectMailSheet({ visible, onClose, onSuccess }: Props) { setPasswordVisible(false); setFormError(null); setFieldsComplete(false); + setOauthRunning(false); + setOauthError(null); onClose(); } @@ -156,7 +163,65 @@ export function ConnectMailSheet({ visible, onClose, onSuccess }: Props) { setTitle(defaultTitleForProvider(provider)); setFormError(null); setFieldsComplete(false); - setView('form'); + setOauthError(null); + if (provider.authMethod === 'oauth_microsoft') { + setView('oauth_warning'); + } else { + setView('form'); + } + } + + async function handleOAuthStart() { + setOauthRunning(true); + setOauthError(null); + setView('oauth_pending'); + try { + const { authorizationUrl } = await apiFetch<{ authorizationUrl: string }>( + '/api/mail/oauth/microsoft/init', + { method: 'POST', body: email.trim() ? { email: email.trim() } : {} } + ); + + try { + await WebBrowser.dismissAuthSession(); + } catch {} + + const result = await WebBrowser.openAuthSessionAsync( + authorizationUrl, + 'rebreak://auth/mail-oauth-callback' + ); + + if (result.type !== 'success') { + setOauthError(t('mail.oauth.error_aborted')); + setView('oauth_warning'); + setOauthRunning(false); + return; + } + + const url = new URL(result.url); + const code = url.searchParams.get('code'); + const state = url.searchParams.get('state'); + + if (!code || !state) { + setOauthError(t('mail.oauth.error_no_code')); + setView('oauth_warning'); + setOauthRunning(false); + return; + } + + const conn = await apiFetch<{ connectionId: string; email: string; provider: string; title: null }>( + '/api/mail/oauth/microsoft/callback', + { method: 'POST', body: { code, state } } + ); + + setPendingOAuthConnectionId(conn.connectionId); + setOauthRunning(false); + handleClose(); + onSuccess(); + } catch (e: any) { + setOauthError(t('mail.oauth.error_callback_failed')); + setView('oauth_warning'); + setOauthRunning(false); + } } async function handleConnect() { @@ -200,7 +265,9 @@ export function ConnectMailSheet({ visible, onClose, onSuccess }: Props) { const sheetTitle = view === 'form' && selectedProvider ? t(selectedProvider.labelKey) - : t('mail.connect_sheet_title'); + : view === 'oauth_warning' || view === 'oauth_pending' + ? t('mail.provider_outlook') + : t('mail.connect_sheet_title'); return ( ) : view === 'grid' ? ( + ) : view === 'oauth_warning' ? ( + setView('grid')} + t={t} + colors={colors} + /> + ) : view === 'oauth_pending' ? ( + ) : ( void; + onCancel: () => void; + t: (key: string) => string; + colors: ReturnType; +}) { + return ( + + + + + + {t('mail.oauth.warning_title')} + + + + {t('mail.oauth.warning_body')} + + + + {error ? ( + + {error} + + ) : null} + + + + + {t('mail.oauth.warning_continue')} + + + + + + + {t('mail.oauth.warning_cancel')} + + + + ); +} + +// --------------------------------------------------------------------------- +// Sub-View: OAuth Pending (Browser läuft gerade) +// --------------------------------------------------------------------------- + +function OAuthPendingStep({ + t, + colors, +}: { + t: (key: string) => string; + colors: ReturnType; +}) { + return ( + + + + {t('mail.oauth.pending_label')} + + + {t('mail.oauth.pending_hint')} + + + ); +} + // --------------------------------------------------------------------------- // Sub-View: Provider-Grid // --------------------------------------------------------------------------- diff --git a/apps/rebreak-native/components/mail/MailAccountCard.tsx b/apps/rebreak-native/components/mail/MailAccountCard.tsx index f10755e..af9b11d 100644 --- a/apps/rebreak-native/components/mail/MailAccountCard.tsx +++ b/apps/rebreak-native/components/mail/MailAccountCard.tsx @@ -2,6 +2,8 @@ import { useState } from 'react'; import { ActivityIndicator, LayoutAnimation, + Linking, + Modal, Platform, TouchableOpacity, Text, @@ -65,6 +67,10 @@ function resolveProviderIcon(provider: string): { return { icon: 'server', color: '#737373' }; } +function isOAuthProvider(provider: string): boolean { + return provider === 'outlook_oauth'; +} + const STALE_THRESHOLD_MS = 5 * 60 * 1_000; const IDLE_HEARTBEAT_STALE_MS = STALE_THRESHOLD_MS; const NO_NEW_MAIL_THRESHOLD_MS = 60 * 60_000; @@ -319,6 +325,121 @@ function SettingsRow({ ); } +function OAuthDisconnectHintModal({ + visible, + onClose, + t, +}: { + visible: boolean; + onClose: () => void; + t: (key: string) => string; +}) { + return ( + + + {}} style={{ width: '88%', maxWidth: 340 }}> + + + + + + + {t('mail.oauth.disconnect_hint_title')} + + + + {t('mail.oauth.disconnect_hint_body')} + + + + Linking.openURL('https://account.microsoft.com/consent').catch(() => {})} + style={{ + flex: 1, + paddingVertical: 10, + borderRadius: 10, + backgroundColor: '#eff6ff', + borderWidth: 1, + borderColor: '#bfdbfe', + alignItems: 'center', + }} + > + + {t('mail.oauth.disconnect_hint_open_ms')} + + + + + + OK + + + + + + + + ); +} + export function MailAccountCard({ account, plan, @@ -333,10 +454,12 @@ export function MailAccountCard({ const [confirmVisible, setConfirmVisible] = useState(false); const [editPasswordVisible, setEditPasswordVisible] = useState(false); const [editTitleVisible, setEditTitleVisible] = useState(false); + const [oauthDisconnectHintVisible, setOauthDisconnectHintVisible] = useState(false); const [localTitle, setLocalTitle] = useState(account.title ?? null); const { setInterval, updating } = useMailInterval(); const { icon, color } = resolveProviderIcon(account.provider); + const isOAuth = isOAuthProvider(account.provider); const isLegend = plan === 'legend'; const isPaused = account.paused === true; const intervalOptions = INTERVAL_OPTIONS_BY_PLAN[plan]; @@ -602,12 +725,14 @@ export function MailAccountCard({ value={account.email} /> - setEditPasswordVisible(true)} - /> + {!isOAuth && ( + setEditPasswordVisible(true)} + /> + )} { setConfirmVisible(false); await onDisconnect(account.id); + if (isOAuth) setOauthDisconnectHintVisible(true); }} onCancel={() => setConfirmVisible(false)} /> - setEditPasswordVisible(false)} - onSuccess={onEditSuccess} + setOauthDisconnectHintVisible(false)} + t={t} /> + {!isOAuth && ( + setEditPasswordVisible(false)} + onSuccess={onEditSuccess} + /> + )} + void; + setView: (view: MailConnectDraftState['view']) => void; setConsentGiven: (v: boolean) => void; setSelectedProvider: (p: ProviderSnapshot | null) => void; setEmail: (email: string) => void; setTitle: (title: string) => void; + setPendingOAuthConnectionId: (id: string | null) => void; reset: () => void; }; const INITIAL: Pick< MailConnectDraftState, - 'view' | 'consentGiven' | 'selectedProvider' | 'email' | 'title' + 'view' | 'consentGiven' | 'selectedProvider' | 'email' | 'title' | 'pendingOAuthConnectionId' > = { view: 'consent', consentGiven: false, selectedProvider: null, email: '', title: '', + pendingOAuthConnectionId: null, }; export const useMailConnectDraft = create((set) => ({ @@ -46,5 +51,6 @@ export const useMailConnectDraft = create((set) => ({ setSelectedProvider: (selectedProvider) => set({ selectedProvider }), setEmail: (email) => set({ email }), setTitle: (title) => set({ title }), + setPendingOAuthConnectionId: (pendingOAuthConnectionId) => set({ pendingOAuthConnectionId }), reset: () => set(INITIAL), })); diff --git a/backend/docs/mail-outlook-oauth-dsgvo-review.md b/backend/docs/mail-outlook-oauth-dsgvo-review.md index 95173a9..24557ab 100644 --- a/backend/docs/mail-outlook-oauth-dsgvo-review.md +++ b/backend/docs/mail-outlook-oauth-dsgvo-review.md @@ -15,7 +15,7 @@ - Die OAuth2-Integration ist datenschutzrechtlich **günstiger als die bisherige App-Passwort-Lösung** (granular widerrufbare Scopes, Token-Rotation, kein Klartext-Credential bei Rebreak). - Microsoft ist als Auftragsverarbeiter für Exchange Online seit Februar 2025 vollständig im **EU Data Boundary** — der Drittland-Transfer ist damit deutlich entschärft, aber **nicht vollständig eliminiert** (Support-Zugriffe, Telemetrie, Identity-Platform-Subsysteme können noch US-Routing enthalten). - Microsofts Standard-**DPA** (Stand September 2025) erfüllt die Art. 28-Anforderungen formal — eine eigenständige Vertragsunterzeichnung ist für Consumer-/Free-Tier-OAuth in der Regel **nicht möglich**; die DPA gilt qua Akzeptanz der Microsoft Services Agreement bzw. App-Registration-Bedingungen. Dies sollten Sie einmal anwaltlich verifizieren lassen, da Rebreak hier als „Partner" und nicht als zahlender M365-Tenant agiert. -- **Kein Blocker** für Go-Live, aber Pflicht-Aufgaben (VVT, Datenschutzerklärung, User-Einwilligungstext, Token-Revoke-Logik beim Löschen) **vor** Aktivierung. +- **Kein Blocker** für Go-Live, aber Pflicht-Aufgaben (VVT, Datenschutzerklärung, User-Einwilligungstext, vollständige DB-seitige Token-Löschung + User-Information zum manuellen Entfernen unter `account.microsoft.com`, siehe Section 5.1) **vor** Aktivierung. --- @@ -68,13 +68,13 @@ Microsofts „[Products and Services Data Protection Addendum (DPA)](https://aka | **Zweck** | Erkennung und Löschung von Glücksspiel-Werbemails im Nutzer-Postfach (Sucht-Trigger-Minimierung) | | **Rechtsgrundlage** | Art. 6 Abs. 1 lit. b DSGVO (Vertragserfüllung) i.V.m. Art. 9 Abs. 2 lit. a DSGVO (ausdrückliche Einwilligung) — Wechsel auf Art. 9 Abs. 2 lit. h bei DiGA-Listung | | **Betroffene Personen** | Registrierte Rebreak-Nutzer mit Microsoft-Consumer-Postfach (outlook.com, hotmail.com, live.com, msn.com) | -| **Datenkategorien (Art. 6)** | E-Mail-Adresse, OAuth-Access-Token, OAuth-Refresh-Token, Token-Ablaufdatum, technische Verbindungs-Metadaten (IMAP-Session-Logs) | +| **Datenkategorien (Art. 6)** | E-Mail-Adresse des Microsoft-Kontos, Display-Name des Microsoft-Kontos, OAuth-Access-Token, OAuth-Refresh-Token, Token-Ablaufdatum, technische Verbindungs-Metadaten (IMAP-Session-Logs) | | **Datenkategorien (Art. 9)** | Indirekt: Verbindung „MS-Account-Inhaber X nutzt Anti-Glücksspiel-App" → Rückschluss auf Suchterkrankung möglich (siehe Abschnitt 6) | | **Empfänger / Sub-AV** | Microsoft Ireland Operations Ltd., One Microsoft Place, South County Business Park, Leopardstown, Dublin 18, Irland | | **Drittland-Transfer** | Primär EU (EU Data Boundary, Exchange Online), residuale Transfers in USA für Identity-Platform/Support (SCCs 2021/914 + EU-US DPF) | | **Speicherdauer** | Tokens: bis User-Disconnect oder 90 Tage Inaktivität (refresh-token-TTL); Verbindungs-Logs: 30 Tage rolling | | **TOMs** | AES-256-Encryption-at-rest für Tokens, TLS 1.2+ in Transit, Zugriff auf Token-Tabelle nur durch Backend-Service-Account, Hetzner DE Hosting | -| **Löschkonzept** | Bei User-Disconnect oder Account-Löschung: aktiver Token-Revoke bei MS (`/oauth2/v2.0/logout` bzw. revoke-endpoint) + DB-Row-Löschung | +| **Löschkonzept** | Bei User-Disconnect oder Account-Löschung: vollständige Löschung aller Token-/Profil-Daten aus der Rebreak-DB. Microsoft stellt keinen Drittanbieter-Token-Revoke-Endpoint bereit (siehe Section 5.1) — daher zusätzlich User-Information mit Anleitung zum manuellen Entfernen unter `account.microsoft.com → Sicherheit → Berechtigungen für Apps`. | ### 3.2 Sub-AV-Liste aktualisieren @@ -107,12 +107,13 @@ Microsofts „[Products and Services Data Protection Addendum (DPA)](https://aka > - `IMAP.AccessAsUser.All` (Lese-/Lösch-Zugriff auf Ihre E-Mail-Inbox) > - `offline_access` (technischer Refresh-Token-Bezug) > - `openid` (OAuth-Mindesthygiene) +> - `User.Read` (Anzeige Ihres Namens in der Rebreak-App nach Verbindungs-Aufbau) > -> Wir lesen **keine** weiteren Daten: keine Kontakte, keine Kalender, kein Profil, keine Fotos. +> Aus Ihrem Microsoft-Konto-Profil lesen wir ausschließlich Ihren angezeigten Namen und Ihre E-Mail-Adresse (Scope `User.Read`), um eine freundliche Anzeige der verbundenen Verbindung in der App zu ermöglichen („Verbunden als …"). Keine weiteren Profil-Daten wie Kontakte, Kalender, Fotos, Telefonnummer, Job-Titel oder Manager-Informationen werden gelesen — auch wenn der Scope `User.Read` dies technisch erlauben würde. Wir wenden hier das Prinzip der Datenminimierung (Art. 5 Abs. 1 lit. c DSGVO) durch eine **technische Selbstbeschränkung im Client-Code** an. > > Sub-Auftragsverarbeiter ist Microsoft Ireland Operations Ltd. (Dublin, Irland). Die Postfach-Verarbeitung erfolgt innerhalb des Microsoft EU Data Boundary (EU/EFTA). In Einzelfällen (Identity-Platform, Support) können Restdaten in die USA übermittelt werden — abgesichert durch die EU-Standardvertragsklauseln (Modul 2/3) und das EU-US Data Privacy Framework. > -> Sie können diese Verbindung jederzeit in den Rebreak-Einstellungen trennen. Wir widerrufen den Token in diesem Fall aktiv bei Microsoft. +> Sie können diese Verbindung jederzeit in den Rebreak-Einstellungen trennen. Wir löschen in diesem Fall alle bei uns gespeicherten Tokens und Profil-Daten vollständig aus unserer Datenbank. Microsoft stellt Drittanbieter-Apps technisch keinen Token-Widerruf-Endpoint zur Verfügung — für eine **vollständige** Entfernung der Rebreak-Berechtigung in Ihrem Microsoft-Konto empfehlen wir Ihnen daher, zusätzlich unter `account.microsoft.com → Sicherheit → Berechtigungen für Apps` die App „Rebreak" zu entfernen. Wir blenden Ihnen direkt nach dem Trennen in der App eine Schritt-für-Schritt-Anleitung dazu ein. ### 4.2 Unterschied „OAuth-Token-Storage vs App-Passwort-Storage" @@ -124,12 +125,28 @@ Microsofts „[Products and Services Data Protection Addendum (DPA)](https://aka ### 5.1 Recht auf Löschung (Art. 17) -**Pflicht, nicht Best-Practice:** Bei User-Löschung MÜSSEN Sie den Refresh-Token **aktiv bei Microsoft revoken**, bevor Sie die DB-Row entfernen. +**Technische Einschränkung — Microsoft stellt keinen Drittanbieter-Revoke bereit:** -→ **Spec für rebreak-backend:** Beim Disconnect/Delete-Flow: -1. Call `POST https://login.microsoftonline.com/common/oauth2/v2.0/logout` mit Refresh-Token (best-effort, mit Retry) -2. Error-Logging falls revoke fehlschlägt — aber DB-Row trotzdem nach max. 3 Retries löschen -3. Audit-Log-Eintrag „token revoked at MS: success/failure" +Bei der Implementierung des Disconnect-Flows hat sich bestätigt: Die Microsoft Identity Platform **unterstützt keinen RFC-7009-konformen `revoke_token`-Endpoint** für Drittanbieter-Apps. Der einzige technisch verfügbare Pfad `/oauth2/v2.0/logout` beendet lediglich eine User-Browser-Session, **invalidiert aber den Refresh-Token nicht** ([Microsoft Q&A: Identity Platform OAuth2 Revoke Access](https://learn.microsoft.com/en-us/answers/questions/890165/identity-platform-oauth2-revoke-acess), [Microsoft Q&A: How to revoke OAuth refresh token?](https://learn.microsoft.com/en-us/answers/questions/986743/how-to-revoke-oauth-refresh-token)). + +Refresh-Tokens werden serverseitig bei Microsoft nur in den folgenden Fällen invalidiert: +1. Der User ändert sein Microsoft-Passwort. +2. Der User entfernt die Rebreak-App manuell unter `account.microsoft.com → Sicherheit → Berechtigungen für Apps`. +3. Die `revokeSignInSessions`-Graph-API wird vom User selbst aufgerufen (invalidiert dann **alle** Refresh-Tokens des Users, nicht nur Rebreak — daher kein geeigneter Anwendungs-Initiierungs-Pfad). +4. Ablauf der Token-Lebenszeit (90 Tage Inaktivität). + +**Bewertung im Lichte von Art. 17 DSGVO:** + +> Microsofts OAuth-Plattform unterstützt keinen serverseitigen Token-Widerruf durch Drittanbieter-Apps. Rebreak löscht die gespeicherten Tokens beim User-Disconnect aus der eigenen Datenbank vollständig. Die User-Information enthält den expliziten Hinweis, dass für eine **vollständige** Trennung die Rebreak-App auch in den Microsoft-Konto-Einstellungen (`account.microsoft.com → Sicherheit → Berechtigungen für Apps`) entfernt werden sollte. Damit ist die Datenminimierungs-Pflicht aus Art. 17 in dem Umfang erfüllt, in dem Microsoft als Identity Provider technische Mittel zur Verfügung stellt. + +→ **Spec für rebreak-backend + rebreak-native:** Beim Disconnect/Delete-Flow: +1. **DB-seitig:** Access-Token, Refresh-Token, Token-Ablaufdatum, Display-Name und gespeicherte E-Mail-Adresse des MS-Kontos **vollständig** aus der Datenbank entfernen (keine Soft-Delete-Flags auf Token-Spalten). +2. **Audit-Log:** Eintrag „mail_connection deleted: provider=microsoft, user_id=…, timestamp=…" — **ohne** Token-Inhalt. +3. **User-Information (in-app, sofort nach Disconnect-Bestätigung):** Hinweis-Screen mit Schritt-für-Schritt-Anleitung: + > „Wir haben Ihre Outlook-Verbindung in Rebreak vollständig gelöscht. Microsoft selbst behält den Zugriffs-Token jedoch bis zur nächsten Inaktivitäts-Phase (max. 90 Tage). Für eine **vollständige sofortige Trennung** öffnen Sie bitte `account.microsoft.com` → Sicherheit → „App-Berechtigungen verwalten" → wählen Sie „Rebreak" → „Diese App entfernen". +4. **Best-effort Logout-Call:** Optionaler Call an `/oauth2/v2.0/logout` ist möglich, hat aber **keinen** Token-revoking Effekt — daher nicht als Compliance-Maßnahme dokumentieren. + +**Offene Anwalts-Frage** (siehe Section 9): Ob die User-Information als alleinige Maßnahme genügt, oder ob zusätzlich z. B. eine E-Mail-Erinnerung nach X Tagen versendet werden sollte. ### 5.2 Auskunftspflicht (Art. 15) @@ -185,12 +202,14 @@ Wenn ein User im OAuth-Consent-Screen liest „Rebreak (Anti-Glücksspiel-App) m | 2 | Sub-AV-Liste in Datenschutzerklärung um Microsoft Ireland erweitern | Brini + Anwalt-Review | **vor Go-Live** | **Ja** | | 3 | Datenschutzerklärungs-Textbaustein „Outlook-Anbindung" + „OAuth vs App-Passwort" einfügen | Brini + Anwalt-Review | **vor Go-Live** | **Ja** | | 4 | Art. 9-Einwilligungs-Flow im Mail-Connect-Onboarding implementieren (sofern noch nicht für andere Provider vorhanden) | rebreak-native + rebreak-backend | **vor Go-Live** | **Ja** | -| 5 | Token-Revoke-Logik (`/oauth2/v2.0/logout`) bei Disconnect + Account-Löschung implementieren | rebreak-backend | **vor Go-Live** | **Ja** | -| 6 | Datenexport-Endpoint (Art. 15) um `mail_connections`-Block ergänzen, falls nicht vorhanden | rebreak-backend | binnen 30 Tagen nach Go-Live | Nein | -| 7 | TIA (Transfer Impact Assessment, 2-3 Seiten) für MS-Sub-AV erstellen | DSB-Draft, Brini-Freigabe | binnen 30 Tagen nach Go-Live | Nein (aber dringend) | -| 8 | Anwaltliche Klärung „greift MS-DPA bei reiner OAuth-App-Registration?" | Anwalt | binnen 60 Tagen | Nein, aber Risiko-Minderung | -| 9 | Microsoft-Sub-AV in DiGA-Datenschutz-Konzept einbauen | DSB + rebreak-strategist | wenn DiGA-Antrag aktuell wird | Nein | -| 10 | Bestehenden VVT auf Konsistenz prüfen (Gmail/iCloud/GMX als Sub-AV?) | DSB-Audit | binnen 60 Tagen | Nein (aber wichtig für Konsistenz) | +| 5 | Vollständige DB-seitige Löschung aller MS-Token-/Profil-Daten bei Disconnect + Account-Löschung (kein Soft-Delete) | rebreak-backend | **vor Go-Live** | **Ja** | +| 6 | User-Information-Screen bei Disconnect mit Schritt-für-Schritt-Anleitung zum manuellen Entfernen unter `account.microsoft.com` (siehe Section 5.1) | rebreak-native + rebreak-backend | **vor Go-Live** | **Ja** | +| 7 | Datenexport-Endpoint (Art. 15) um `mail_connections`-Block (inkl. Display-Name + erteilte Scopes) ergänzen, falls nicht vorhanden | rebreak-backend | binnen 30 Tagen nach Go-Live | Nein | +| 8 | TIA (Transfer Impact Assessment, 2-3 Seiten) für MS-Sub-AV erstellen | DSB-Draft, Brini-Freigabe | binnen 30 Tagen nach Go-Live | Nein (aber dringend) | +| 9 | Anwaltliche Klärung „greift MS-DPA bei reiner OAuth-App-Registration?" | Anwalt | binnen 60 Tagen | Nein, aber Risiko-Minderung | +| 10 | Anwaltliche Klärung „User-Information vs. zusätzlicher Mail-Reminder zur Erfüllung Art. 17" (siehe Section 9) | Anwalt | binnen 60 Tagen | Nein | +| 11 | Microsoft-Sub-AV in DiGA-Datenschutz-Konzept einbauen | DSB + rebreak-strategist | wenn DiGA-Antrag aktuell wird | Nein | +| 12 | Bestehenden VVT auf Konsistenz prüfen (Gmail/iCloud/GMX als Sub-AV?) | DSB-Audit | binnen 60 Tagen | Nein (aber wichtig für Konsistenz) | --- @@ -200,6 +219,7 @@ Wenn ein User im OAuth-Consent-Screen liest „Rebreak (Anti-Glücksspiel-App) m 2. **Finaler Wortlaut der Einwilligungserklärung Art. 9** — Einwilligungstexte sollten anwaltlich gegen UWG/AGB-Recht geprüft sein. 3. **Finaler Wortlaut der Datenschutzerklärungs-Änderungen** — Ich liefere DSB-Vorlagen, die juristische Abnahme bleibt Anwalt. 4. **AGB-Anpassung** für das veränderte Verfahren (App-Passwort → OAuth). +5. **Art. 17-Erfüllung trotz fehlendem MS-Revoke-Endpoint** (siehe Section 5.1) — konkret: Erfüllt die in-app User-Information mit Anleitung zum manuellen Entfernen unter `account.microsoft.com` die Art. 17-Pflicht ausreichend, oder müssen wir zusätzlich z. B. eine E-Mail-Erinnerung nach X Tagen versenden, bzw. eine schriftliche Bestätigung der erfolgten Entfernung anfordern? Hier wäre auch eine kurze Einschätzung wertvoll, ob die fehlende technische Revoke-Möglichkeit als Haftungs-Risiko für Rebreak einzustufen ist oder als „auf Seite des Identity Providers liegend" akzeptiert wird. --- @@ -211,6 +231,10 @@ Wenn ein User im OAuth-Consent-Screen liest „Rebreak (Anti-Glücksspiel-App) m - [Microsoft EU Data Boundary Completion — Microsoft On the Issues, 26.02.2025](https://blogs.microsoft.com/on-the-issues/2025/02/26/microsoft-completes-landmark-eu-data-boundary-offering-enhanced-data-residency-and-transparency/) - [Microsoft European Digital Commitments — One Year On, 29.04.2026](https://blogs.microsoft.com/on-the-issues/2026/04/29/one-year-on-progress-on-our-european-digital-commitments/) - [Microsoft Identity Platform — OIDC Single Sign-Out / Token Revocation](https://learn.microsoft.com/en-us/entra/identity-platform/v2-protocols-oidc#single-sign-out) +- [Microsoft Q&A — Identity Platform OAuth2 Revoke Access (kein Drittanbieter-Revoke-Endpoint)](https://learn.microsoft.com/en-us/answers/questions/890165/identity-platform-oauth2-revoke-acess) +- [Microsoft Q&A — How to revoke OAuth refresh token? (Bestätigung der technischen Limitation)](https://learn.microsoft.com/en-us/answers/questions/986743/how-to-revoke-oauth-refresh-token) +- [Microsoft Learn — Refresh tokens in the Microsoft identity platform](https://learn.microsoft.com/en-us/entra/identity-platform/refresh-tokens) +- [Microsoft Graph — `revokeSignInSessions` (invalidiert alle Refresh-Tokens des Users, nicht nur Rebreak)](https://learn.microsoft.com/en-us/graph/api/user-revokesigninsessions) - [EU-Standardvertragsklauseln 2021/914 — Europäische Kommission](https://commission.europa.eu/publications/standard-contractual-clauses-controllers-and-processors-eueea_en) - [EDPB Recommendations 01/2020 — Supplementary Measures (TIA-Grundlage)](https://www.edpb.europa.eu/our-work-tools/our-documents/recommendations/recommendations-012020-measures-supplement-transfer_en) - [BfArM DiGA-Leitfaden (Datenschutz-Anforderungen)](https://www.bfarm.de/DE/Medizinprodukte/Aufgaben/DiGA/_node.html) diff --git a/backend/imap-idle/index.mjs b/backend/imap-idle/index.mjs index 86f2290..6058f78 100644 --- a/backend/imap-idle/index.mjs +++ b/backend/imap-idle/index.mjs @@ -5,11 +5,16 @@ * Wenn der Server "EXISTS" meldet (neue Mail), feuert der Daemon sofort * POST /api/mail/scan-internal gegen das lokale Backend — ohne 30min-Warte. * + * Auth-Methoden: + * app_password — Gmail, iCloud, GMX, etc. (App-Password / IMAP-Passwort) + * oauth2_microsoft — Outlook / Hotmail / O365 via XOAUTH2 (ImapFlow-nativ) + * * Env-Vars (via Infisical-Wrapper): * DATABASE_URL — Postgres-Connection-String * ADMIN_SECRET — Header-Secret für /api/mail/scan-internal - * ENCRYPTION_KEY — AES-256 Key (gleicher wie im Backend) + * ENCRYPTION_KEY — AES-256 Key (gleicher wie im Backend, 32+ Zeichen) * BACKEND_URL — z.B. http://127.0.0.1:3016 (default: 3016) + * MS_OAUTH_CLIENT_ID — Azure App Registration Client ID * NODE_ENV — production / staging * * Starten: @@ -21,9 +26,9 @@ import { ImapFlow } from "imapflow"; import pg from "pg"; -import { createDecipheriv } from "crypto"; +import { createCipheriv, createDecipheriv, randomBytes } from "crypto"; -// ─── Config ───────────────────────────────────────────────────────────────── +// ─── Config ────────────────────────────────────────────────────────────────── const BACKEND_URL = process.env.BACKEND_URL || @@ -34,19 +39,37 @@ const BACKEND_URL = const ADMIN_SECRET = process.env.NUXT_ADMIN_SECRET || process.env.ADMIN_SECRET || ""; +const MS_OAUTH_CLIENT_ID = + process.env.MS_OAUTH_CLIENT_ID || ""; + const DB_REFRESH_INTERVAL_MS = 5 * 60 * 1000; // 5 min — neue Connections entdecken + // IDLE_RENEW von 25min → 10min: GMX dropped IDLE-connections silent vor 25min // → exists-events kommen nie an + ImapFlow.idle() hängt ohne reject. 10min -// deckt alle bekannten Provider-Timeouts ab (GMX ~10-15min, Gmail ~29min, -// iCloud ~29min, Outlook ~29min). Trade-off: alle 10min full reconnect-cycle. -const IDLE_RENEW_INTERVAL_MS = 10 * 60 * 1000; // 10 min (war 25) -// NOOP-heartbeat alle 2min während IDLE: defensive check ob connection wirklich -// alive ist. Wenn NOOP fehlschlägt → close + reconnect-loop. -const IDLE_NOOP_INTERVAL_MS = 2 * 60 * 1000; // 2 min — silent-drop early-detection -const RECONNECT_DELAYS_MS = [1000, 5000, 30_000]; // exponential backoff, danach 60s loop +// deckt alle bekannten Provider-Timeouts ab: +// GMX ~10-15min (aggressivster Provider) +// Gmail ~29min +// iCloud ~29min +// outlook.office365.com ~29min (Microsoft dokumentiert 29min IDLE-Timeout) +// Trade-off: alle 10min full reconnect-cycle. Vertretbar. +const IDLE_RENEW_INTERVAL_MS = 10 * 60 * 1000; // 10 min + +// NOOP-heartbeat alle 2min während IDLE: detect silent-drops (GMX-pattern). +// Wenn NOOP fehlschlägt → close → loop iteriert → reconnect. +const IDLE_NOOP_INTERVAL_MS = 2 * 60 * 1000; // 2 min + +// Token-Refresh-Schwelle: wenn Access-Token in weniger als 5min abläuft, vor +// dem IMAP-Connect refreshen. Verhindert Mid-Session-Expiry. +const TOKEN_EXPIRY_THRESHOLD_MS = 5 * 60 * 1000; // 5 min + +// Bei AUTHENTICATIONFAILED: max. 3 Refresh-Versuche bevor Connection als +// auth_broken markiert und der Daemon sie aufgibt. +const MAX_AUTH_RETRIES = 3; + +const RECONNECT_DELAYS_MS = [1000, 5000, 30_000]; // exponential backoff const RECONNECT_LOOP_DELAY_MS = 60 * 1000; -// ─── DB-Pool (direktes pg, kein Prisma — Daemon ist kein Nitro-Kontext) ──── +// ─── DB-Pool (direktes pg, kein Prisma — Daemon ist kein Nitro-Kontext) ───── const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL }); @@ -77,25 +100,33 @@ async function updateIdleHeartbeat(connId) { ); } +/** + * Lädt alle aktiven MailConnections inkl. OAuth-Felder und consent_at. + * Consent-Gate: connections ohne consent_at werden geladen aber vom + * IDLE-Loop nicht gescannt (nur gehalten — kein Delete). + */ async function loadActiveConnections() { - // DB-table heißt "mail_connections" + snake_case columns (Prisma @map). - // Aliase auf camelCase damit der restliche Daemon-Code unverändert bleibt. const { rows } = await pool.query( `SELECT id, - user_id AS "userId", + user_id AS "userId", email, - imap_host AS "imapHost", - imap_port AS "imapPort", - password_encrypted AS "passwordEncrypted", - reject_unauthorized AS "rejectUnauthorized", - use_starttls AS "useStarttls" + imap_host AS "imapHost", + imap_port AS "imapPort", + password_encrypted AS "passwordEncrypted", + reject_unauthorized AS "rejectUnauthorized", + use_starttls AS "useStarttls", + auth_method AS "authMethod", + oauth_access_token AS "oauthAccessToken", + oauth_refresh_token AS "oauthRefreshToken", + oauth_token_expiry AS "oauthTokenExpiry", + consent_at AS "consentAt" FROM rebreak.mail_connections WHERE is_active = true`, ); return rows; } -// ─── Crypto (analog zu server/utils/crypto.ts) ────────────────────────────── +// ─── Crypto (analog zu server/utils/crypto.ts) ─────────────────────────────── const AES_ALGO = "aes-256-gcm"; const KEY_LENGTH = 32; @@ -125,21 +156,218 @@ function decrypt(stored) { return decipher.update(Buffer.from(dataHex, "hex")) + decipher.final("utf8"); } -// ─── Logging ──────────────────────────────────────────────────────────────── +function encrypt(plaintext) { + const key = getKey(); + const iv = randomBytes(12); // 96-bit IV für AES-256-GCM + const cipher = createCipheriv(AES_ALGO, key, iv); + const encrypted = Buffer.concat([ + cipher.update(plaintext, "utf8"), + cipher.final(), + ]); + const tag = cipher.getAuthTag(); + return `${iv.toString("hex")}:${tag.toString("hex")}:${encrypted.toString("hex")}`; +} + +// ─── Microsoft Token Refresh (inline, kein Prisma-Kontext) ─────────────────── + +const MS_TOKEN_ENDPOINT = + "https://login.microsoftonline.com/common/oauth2/v2.0/token"; + +const MS_OAUTH_SCOPES = [ + "https://outlook.office.com/IMAP.AccessAsUser.All", + "offline_access", + "openid", + "User.Read", +].join(" "); + +/** + * Refresht Microsoft Access+Refresh-Token direkt via HTTP. + * Race-Condition-Strategie (Optimistic Concurrency): + * 1. Lese aktuelle oauth_token_expiry aus DB als "Fingerprint" des aktuellen Stands. + * 2. POST an MS Token-Endpoint. + * 3. UPDATE ... WHERE oauth_token_expiry = + * → affected_rows = 0: anderer Prozess hat parallel refresht. + * Dann: frischen token lesen + zurückgeben (kein doppelter Refresh!). + * Doppelter Refresh würde MS-Refresh-Token-Rotation verletzen → + * AADSTS70043 beim nächsten Versuch. + * 4. Gibt plaintext Access-Token zurück, sofort für IMAP nutzbar. + * + * Wirft wenn: + * - Connection nicht gefunden / kein Refresh-Token + * - MS Token-Endpoint antwortet mit Fehler (revoked/expired Refresh-Token) + */ +async function refreshAndSaveTokensDaemon(connectionId, clientId) { + // Step 1: Aktuellen Token-Stand lesen + const { rows } = await pool.query( + `SELECT oauth_refresh_token, oauth_access_token, oauth_token_expiry + FROM rebreak.mail_connections + WHERE id = $1 AND auth_method = 'oauth2_microsoft'`, + [connectionId], + ); + + const conn = rows[0]; + if (!conn?.oauth_refresh_token) { + throw new Error( + `Connection ${connectionId} has no oauth_refresh_token — cannot refresh`, + ); + } + + const currentExpiry = conn.oauth_token_expiry; + const decryptedRefreshToken = decrypt(conn.oauth_refresh_token); + + // Step 2: MS Token-Endpoint + const body = new URLSearchParams({ + grant_type: "refresh_token", + client_id: clientId, + refresh_token: decryptedRefreshToken, + scope: MS_OAUTH_SCOPES, + }); + + const res = await fetch(MS_TOKEN_ENDPOINT, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: body.toString(), + }); + + if (!res.ok) { + const errText = await res.text().catch(() => "unknown"); + throw new Error(`MS token refresh failed (${res.status}): ${errText}`); + } + + const data = await res.json(); + if (!data.access_token) { + throw new Error("MS refresh response missing access_token"); + } + + const newExpiry = new Date(Date.now() + data.expires_in * 1000); + // MS rotiert Refresh-Tokens — wenn kein neuer kommt, den alten behalten + const newRefreshToken = data.refresh_token ?? decryptedRefreshToken; + + const encryptedAccess = encrypt(data.access_token); + const encryptedRefresh = encrypt(newRefreshToken); + + // Step 3: Optimistic update — nur wenn oauth_token_expiry noch gleich wie gelesen + // IS NOT DISTINCT FROM deckt NULL=NULL korrekt ab (vs. = das NULL!=NULL behandelt) + const result = await pool.query( + `UPDATE rebreak.mail_connections + SET oauth_access_token = $1, + oauth_refresh_token = $2, + oauth_token_expiry = $3 + WHERE id = $4 + AND oauth_token_expiry IS NOT DISTINCT FROM $5`, + [encryptedAccess, encryptedRefresh, newExpiry, connectionId, currentExpiry], + ); + + if (result.rowCount === 0) { + // Step 4: Anderer Prozess hat parallel refresht — lese deren frischen Token + const { rows: fresh } = await pool.query( + `SELECT oauth_access_token FROM rebreak.mail_connections WHERE id = $1`, + [connectionId], + ); + if (!fresh[0]?.oauth_access_token) { + throw new Error( + `Concurrent refresh for ${connectionId} detected but no token found`, + ); + } + return decrypt(fresh[0].oauth_access_token); + } + + return data.access_token; +} + +/** + * Markiert eine Connection als "auth_broken" — kein weiterer Retry im Daemon. + * User sieht den Error im Frontend (last_connect_error = 'auth_revoked'). + */ +async function markConnectionAuthBroken(connectionId) { + await pool.query( + `UPDATE rebreak.mail_connections + SET last_connect_error = 'auth_revoked', + last_connect_error_at = NOW() + WHERE id = $1`, + [connectionId], + ); +} + +// ─── Credentials-Resolution ────────────────────────────────────────────────── + +/** + * Gibt die IMAP-Credentials für eine Connection zurück. + * auth_method-aware: + * + * app_password → { type: 'password', user, pass } + * oauth2_microsoft → { type: 'xoauth2', user, accessToken } + * + * Für OAuth: wenn Token in <5min abläuft, wird vor Connect refresht. + * Das ist der "proaktive" Refresh-Path (Hot-Path). + * + * Der "reaktive" Refresh-Path (Cold-Path) liegt in runSession(): + * AUTHENTICATIONFAILED während laufender Session → refreshAndSaveTokensDaemon() + * → neuer ImapFlow-Connect mit frischem Token. + * + * FORCE_REFRESH_FOR_TEST: wenn env IDLE_FORCE_TOKEN_REFRESH=1 gesetzt ist, + * wird IMMER refresht unabhängig von Expiry. Nur für manuelle Tests. + */ +async function getCredentialsForConnection(conn) { + if (conn.authMethod === "oauth2_microsoft") { + const forceRefresh = process.env.IDLE_FORCE_TOKEN_REFRESH === "1"; + const fiveMinFromNow = Date.now() + TOKEN_EXPIRY_THRESHOLD_MS; + const isExpiring = + !conn.oauthTokenExpiry || + new Date(conn.oauthTokenExpiry).getTime() < fiveMinFromNow; + + if (forceRefresh || isExpiring) { + const reason = forceRefresh ? "IDLE_FORCE_TOKEN_REFRESH=1" : "token expiring <5min"; + // NIEMALS Token-Werte loggen — nur Fakt dass refresht wird + console.log(`[idle/${conn.email}] proactive token refresh (${reason})`); + const accessToken = await refreshAndSaveTokensDaemon(conn.id, MS_OAUTH_CLIENT_ID); + return { type: "xoauth2", user: conn.email, accessToken }; + } + + // Token noch gültig — direkt entschlüsseln + if (!conn.oauthAccessToken) { + throw new Error(`Connection ${conn.id} has no oauth_access_token`); + } + const accessToken = decrypt(conn.oauthAccessToken); + return { type: "xoauth2", user: conn.email, accessToken }; + } + + // Bestand: Gmail / iCloud / GMX / Custom-IMAP → App-Password + if (!conn.passwordEncrypted) { + throw new Error(`Connection ${conn.id} has no password_encrypted`); + } + const pass = decrypt(conn.passwordEncrypted); + return { type: "password", user: conn.email, pass }; +} + +// ─── Logging ───────────────────────────────────────────────────────────────── function log(email, msg) { - // NIEMALS password/credentials loggen. email ist safe (kein secret). + // NIEMALS password/accessToken/credentials loggen. email ist safe. console.log(`[idle/${email}] ${msg}`); } function logError(email, msg, err) { - // responseText enthält z.B. IMAP-Serverantwort bei Auth-Fehlern ("NO [AUTHENTICATIONFAILED]") + // responseText = IMAP-Serverantwort (z.B. "NO [AUTHENTICATIONFAILED] Invalid credentials") + // Credentials tauchen NICHT in err.responseText auf — ImapFlow legt sie nicht rein. const errMsg = err?.responseText || err?.message || String(err); - // Credentials tauchen nie in err.responseText/message auf (ImapFlow maskiert sie nicht, - // aber wir liefern pass nur an ImapFlow — nicht in eigenen log-calls). console.error(`[idle/${email}] ${msg}: ${errMsg}`); } +// ─── Auth-Fehler Detection ─────────────────────────────────────────────────── + +function isAuthError(err) { + const text = (err?.responseText || err?.message || "").toUpperCase(); + return ( + text.includes("AUTHENTICATIONFAILED") || + text.includes("AUTHENTICATE") || + text.includes("INVALID CREDENTIALS") || + text.includes("AUTHENTICATION FAILED") || + // MS-spezifische Fehlercodes: AADSTS = Azure AD Token Service Error + text.includes("AADSTS") + ); +} + // ─── Session-Registry ──────────────────────────────────────────────────────── // Map @@ -147,7 +375,7 @@ const sessions = new Map(); let shuttingDown = false; -// ─── Scan-Trigger ──────────────────────────────────────────────────────────── +// ─── Scan-Trigger ───────────────────────────────────────────────────────────── async function triggerScan(conn) { try { @@ -173,22 +401,42 @@ async function triggerScan(conn) { } } -// ─── IDLE-Session ──────────────────────────────────────────────────────────── +// ─── IDLE-Session ───────────────────────────────────────────────────────────── /** * Startet eine einzelne IDLE-Session für eine MailConnection. * Reconnect-Loop läuft intern — diese Funktion returned nie (bis shutdown). + * + * Auth-Retry-Loop (OAuth-spezifisch): + * Bei AUTHENTICATIONFAILED → Token refreshen → neu verbinden. + * Max MAX_AUTH_RETRIES (3) mal. Dann: markConnectionAuthBroken + exit. + * authRetries wird nach jedem erfolgreichen Connect resettet. + * + * Consent-Gate: + * conn.consentAt === null → IDLE-Verbindung wird gehalten (keep-alive), + * aber triggerScan() wird NICHT aufgerufen. exists-Events werden still ignoriert. + * Sobald der User consent erteilt (DB-Refresh-Cycle nach max. 5min), + * wird die Connection neu gestartet mit consentAt gesetzt. */ async function runSession(conn) { let attempt = 0; + let authRetries = 0; while (!shuttingDown) { - let password; + // Credentials holen (proaktiver Token-Refresh wenn nötig) + let creds; try { - password = decrypt(conn.passwordEncrypted); + creds = await getCredentialsForConnection(conn); } catch (err) { - logError(conn.email, "decrypt failed — session aborted", err); - return; // Kein retry bei Decrypt-Fehler (kaputtes Credential) + logError(conn.email, "credential resolution failed — session aborted", err); + // Kein retry: kaputte Credentials oder Token-Refresh fehlgeschlagen + // (z.B. Refresh-Token revoked). Als auth_broken markieren wenn OAuth. + if (conn.authMethod === "oauth2_microsoft") { + await markConnectionAuthBroken(conn.id).catch(() => {}); + } else { + await updateConnectionError(conn.id, err?.message || String(err)).catch(() => {}); + } + return; } const useImplicitTls = !conn.useStarttls; @@ -197,11 +445,16 @@ async function runSession(conn) { port: conn.imapPort, secure: useImplicitTls, ...(conn.useStarttls ? { requireTLS: true } : {}), - auth: { user: conn.email, pass: password }, + // ImapFlow 1.2.18 unterstützt XOAUTH2 nativ: + // { user, accessToken } → AUTHENTICATE XOAUTH2 + // { user, pass } → LOGIN oder PLAIN (je nach Server-Capability) + auth: creds.type === "xoauth2" + ? { user: creds.user, accessToken: creds.accessToken } + : { user: creds.user, pass: creds.pass }, logger: false, tls: { rejectUnauthorized: conn.rejectUnauthorized ?? true }, - // Outlook schließt idle-connections aggressiv — disableCompression - // verhindert edge-cases bei partial reads nach reconnect + // outlook.office365.com: disableCompression verhindert edge-cases bei + // partial reads nach Reconnect. Gilt für oauth2_microsoft Connections. disableCompression: conn.imapHost.includes("office365"), }); @@ -211,48 +464,53 @@ async function runSession(conn) { try { await imap.connect(); - log(conn.email, `connected (${conn.imapHost}:${conn.imapPort})`); - attempt = 0; // Reset nach erfolgreicher Verbindung - await clearConnectionError(conn.id).catch(() => {}); // clear stale auth-error + log(conn.email, `connected (${conn.imapHost}:${conn.imapPort}, auth=${creds.type})`); + attempt = 0; // Reset nach erfolgreicher Verbindung + authRetries = 0; // Auth-Retry-Counter ebenfalls reset + await clearConnectionError(conn.id).catch(() => {}); await imap.getMailboxLock("INBOX"); + // Consent-Gate-Log: einmalig beim Connect — nur wenn consentAt fehlt + if (!conn.consentAt) { + log( + conn.email, + "consent_at=NULL — IDLE session held but scan/delete suspended. " + + "Re-consent required via app.", + ); + } + // IDLE-Loop: alle IDLE_RENEW_INTERVAL_MS erneuern while (!shuttingDown && sessions.has(conn.id)) { - let idleAbort; - const idlePromise = new Promise((resolve, reject) => { - imap - .idle() - .then(resolve) - .catch(reject); - - idleAbort = () => { - // ImapFlow.idle() bricht ab wenn die Connection getrennt wird - imap.close(); - resolve(); - }; + imap.idle().then(resolve).catch(reject); }); - // exists-event → sofort scannen + // exists-event → sofort scannen (nur wenn consent erteilt) const onExists = () => { + if (!conn.consentAt) { + // Consent fehlt: exists-event ignorieren, Mail bleibt in Inbox. + // UI zeigt Re-Consent-Modal (via /api/mail/pending-consent Endpoint). + log(conn.email, "exists-event received — skipped (no consent_at)"); + return; + } log(conn.email, "exists-event received (new mail)"); triggerScan(conn); // fire-and-forget }; imap.on("exists", onExists); - // IDLE nach 10min erneuern (war 25; GMX dropped silent vor 25min) + // IDLE nach 10min erneuern + // Gilt für: GMX (~10-15min), Gmail (~29min), iCloud (~29min), outlook.office365.com (~29min) const renewTimer = setTimeout(() => { log(conn.email, "idle renewing (10min threshold)"); - imap.close(); // Unterbricht idle() → Loop iteriert → reconnect + imap.close(); }, IDLE_RENEW_INTERVAL_MS); - // NOOP-heartbeat alle 2min: detect silent-IDLE-drops (GMX-pattern). - // Wenn NOOP fehlschlägt → close → loop iteriert → reconnect. + // NOOP-heartbeat alle 2min: silent-drop early-detection (GMX-pattern). const noopTimer = setInterval(async () => { try { await imap.noop(); - await updateIdleHeartbeat(conn.id).catch(() => {}); // UI: "connection alive" + await updateIdleHeartbeat(conn.id).catch(() => {}); } catch (err) { logError(conn.email, "noop failed — connection dead, force reconnect", err); imap.close(); @@ -269,7 +527,6 @@ async function runSession(conn) { if (shuttingDown || !sessions.has(conn.id)) break; - // Kurze Pause vor Reconnect (idle() returned auch bei normalem timeout) await sleep(500); } @@ -278,9 +535,55 @@ async function runSession(conn) { } catch (err) { logError(conn.email, "connection error", err); - const errText = err?.responseText || err?.message || String(err); - await updateConnectionError(conn.id, errText).catch(() => {}); try { imap.close(); } catch { /* ignore */ } + + // ── AUTHENTICATIONFAILED-Recovery (OAuth-spezifisch) ────────────────── + // Cold-Path: Token zwischen zwei IDLE-Renewals abgelaufen (>1h Session). + // Oder: proaktiver Refresh ist fehlgeschlagen und wir landen hier. + if (conn.authMethod === "oauth2_microsoft" && isAuthError(err)) { + authRetries++; + log( + conn.email, + `AUTHENTICATIONFAILED detected — refresh attempt ${authRetries}/${MAX_AUTH_RETRIES}`, + ); + + if (authRetries > MAX_AUTH_RETRIES) { + // Auth dauerhaft revoked (User hat App-Permission entzogen o.ä.) + // Connection als auth_broken markieren — User muss re-connecten. + log(conn.email, `auth_broken after ${MAX_AUTH_RETRIES} retries — session stopped`); + await markConnectionAuthBroken(conn.id).catch(() => {}); + sessions.delete(conn.id); + return; + } + + // Token refreshen — direkt hier im catch-Block. + // Wenn der Refresh selbst fehlschlägt (revoked Refresh-Token), + // wirft refreshAndSaveTokensDaemon — wir landen im äußeren catch + // und der normale Reconnect-Backoff greift (attempt++). + // Beim nächsten Attempt ruft getCredentialsForConnection() wieder refresh auf. + try { + const freshToken = await refreshAndSaveTokensDaemon(conn.id, MS_OAUTH_CLIENT_ID); + // conn ist das ursprüngliche Objekt aus loadActiveConnections. + // Wir patchen oauthAccessToken + oauthTokenExpiry inline damit + // getCredentialsForConnection() beim nächsten Loop-Durchlauf + // den frischen Token nutzt ohne erneuten DB-Read. + conn.oauthAccessToken = encrypt(freshToken); + conn.oauthTokenExpiry = new Date(Date.now() + 55 * 60 * 1000); // ~55min buffer + log(conn.email, "token refreshed — reconnecting immediately"); + // Kein normaler Backoff nach Auth-Refresh — sofort neu verbinden. + // attempt bleibt unverändert (auth-error != network-error). + continue; + } catch (refreshErr) { + logError(conn.email, "token refresh failed after AUTHENTICATIONFAILED", refreshErr); + // Refresh selbst gescheitert → normaler Backoff (Netz, Serverproblem o.ä.) + // authRetries bleibt erhöht — beim nächsten Auth-Fehler zählt es weiter. + await updateConnectionError(conn.id, refreshErr?.message || String(refreshErr)).catch(() => {}); + } + } else { + // Nicht-Auth-Fehler (Netz, TLS, etc.) — normal in DB schreiben + const errText = err?.responseText || err?.message || String(err); + await updateConnectionError(conn.id, errText).catch(() => {}); + } } if (shuttingDown || !sessions.has(conn.id)) return; @@ -296,7 +599,7 @@ async function runSession(conn) { } } -// ─── Session-Management ────────────────────────────────────────────────────── +// ─── Session-Management ─────────────────────────────────────────────────────── function startSession(conn) { if (sessions.has(conn.id)) return; // bereits aktiv @@ -320,7 +623,7 @@ async function stopSession(connectionId, email) { await handle.promise.catch(() => {}); } -// ─── DB-Refresh-Loop ───────────────────────────────────────────────────────── +// ─── DB-Refresh-Loop ────────────────────────────────────────────────────────── async function refreshConnections() { let rows; @@ -347,12 +650,28 @@ async function refreshConnections() { } } + // Consent-Status für laufende Sessions aktualisieren. + // Wenn ein User consent erteilt hat seit dem letzten DB-Refresh: + // Die laufende Session bekommt conn.consentAt gesetzt — next exists-event + // löst dann sofort triggerScan() aus. Kein Restart nötig. + for (const row of rows) { + const handle = sessions.get(row.id); + if (handle && handle.conn.consentAt !== row.consentAt) { + if (!handle.conn.consentAt && row.consentAt) { + log(row.email, "consent_at received — scan/delete now active"); + } + handle.conn.consentAt = row.consentAt; + } + } + + const consentPending = rows.filter((r) => !r.consentAt).length; console.log( - `[idle/db] refreshed — ${activeIds.size} active connections, ${sessions.size} sessions`, + `[idle/db] refreshed — ${activeIds.size} active connections, ${sessions.size} sessions` + + (consentPending > 0 ? `, ${consentPending} pending consent` : ""), ); } -// ─── Graceful Shutdown ─────────────────────────────────────────────────────── +// ─── Graceful Shutdown ──────────────────────────────────────────────────────── async function shutdown(signal) { console.log(`[idle] received ${signal} — shutting down gracefully`); @@ -372,7 +691,7 @@ async function shutdown(signal) { process.on("SIGTERM", () => shutdown("SIGTERM")); process.on("SIGINT", () => shutdown("SIGINT")); -// ─── Startup ───────────────────────────────────────────────────────────────── +// ─── Startup ────────────────────────────────────────────────────────────────── function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); @@ -380,8 +699,9 @@ function sleep(ms) { function assertEnv() { const missing = []; - if (!process.env.DATABASE_URL) missing.push("DATABASE_URL"); - if (!ADMIN_SECRET) missing.push("ADMIN_SECRET / NUXT_ADMIN_SECRET"); + if (!process.env.DATABASE_URL) missing.push("DATABASE_URL"); + if (!ADMIN_SECRET) missing.push("ADMIN_SECRET / NUXT_ADMIN_SECRET"); + if (!MS_OAUTH_CLIENT_ID) missing.push("MS_OAUTH_CLIENT_ID"); if (missing.length > 0) { console.error( `[idle] FEHLER: fehlende Env-Vars: ${missing.join(", ")}. Daemon startet nicht.`, @@ -397,10 +717,8 @@ async function main() { `[idle] starting — backend=${BACKEND_URL} env=${process.env.NODE_ENV ?? "unknown"}`, ); - // Initialer Load await refreshConnections(); - // Periodischer DB-Refresh setInterval(() => { if (!shuttingDown) refreshConnections(); }, DB_REFRESH_INTERVAL_MS); diff --git a/backend/imap-idle/start-idle-staging.sh b/backend/imap-idle/start-idle-staging.sh index df05da9..16987d0 100755 --- a/backend/imap-idle/start-idle-staging.sh +++ b/backend/imap-idle/start-idle-staging.sh @@ -2,7 +2,7 @@ # rebreak-imap-idle Staging — Infisical-Secret-Injection # # Wird von pm2 als Script-Interpreter gestartet (ecosystem.config.js). -# Injiziert DATABASE_URL, ADMIN_SECRET, ENCRYPTION_KEY via Infisical-staging. +# Injiziert DATABASE_URL, ADMIN_SECRET, ENCRYPTION_KEY, MS_OAUTH_CLIENT_ID via Infisical-staging. # ENCRYPTION_KEY muss identisch zum Backend-Key sein (AES-256-GCM). set -euo pipefail @@ -34,5 +34,6 @@ exec infisical run \ export ADMIN_SECRET="${ADMIN_SECRET:-${NUXT_ADMIN_SECRET:-}}" export ENCRYPTION_KEY="${ENCRYPTION_KEY:-${NUXT_ENCRYPTION_KEY:-}}" export BACKEND_URL="http://127.0.0.1:3016" + export MS_OAUTH_CLIENT_ID="${MS_OAUTH_CLIENT_ID:-}" exec '"$NODE_BIN"' '"$DAEMON"' ' diff --git a/backend/nitro.config.ts b/backend/nitro.config.ts index 27296b9..24a8705 100644 --- a/backend/nitro.config.ts +++ b/backend/nitro.config.ts @@ -60,6 +60,13 @@ export default defineNitroConfig({ // ─── Email / External APIs ─────────────────────────────────────────── resendApiKey: process.env.RESEND_API_KEY ?? "", + // ─── Microsoft OAuth (PKCE, Public Client) ─────────────────────────────── + // Client-ID der Azure-App-Registrierung "Rebreak Mail Access". + // Tenant: 'common' (Multi-Tenant + Personal-Accounts) — hardcoded im Code. + // Kein client_secret: Public Client / PKCE-Flow (keine Client-Secret-Exposure). + // Infisical secret name: MS_OAUTH_CLIENT_ID + msOauthClientId: process.env.MS_OAUTH_CLIENT_ID ?? "427575e1-0ec5-4468-b4a2-7ae3ce99a154", + // ─── Bot-User-IDs (DB-User-References für Lyra/Rebreak-Bot-Posts) ──── lyraBotUserId: process.env.LYRA_BOT_USER_ID ?? "", rebreakBotUserId: process.env.REBREAK_BOT_USER_ID ?? "", diff --git a/backend/prisma/migrations/20260513_oauth_pending_states/migration.sql b/backend/prisma/migrations/20260513_oauth_pending_states/migration.sql new file mode 100644 index 0000000..dcca6c6 --- /dev/null +++ b/backend/prisma/migrations/20260513_oauth_pending_states/migration.sql @@ -0,0 +1,26 @@ +-- Migration: 20260513_oauth_pending_states +-- Creates oauth_pending_states table for PKCE-state storage during Microsoft OAuth flow. +-- +-- TTL: entries are short-lived (10 min max). The backend deletes state on callback. +-- Expired entries are garbage-collected at INSERT time in the endpoint (clean-on-write pattern). +-- No background cron needed at this scale. +-- +-- Breaking-change status: NONE — new table, no existing rows affected. +-- Deploy: pnpm prisma migrate deploy (on server via GitHub Actions pipeline) + +CREATE TABLE "rebreak"."oauth_pending_states" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "state_id" TEXT NOT NULL, + "user_id" UUID NOT NULL, + "code_verifier" TEXT NOT NULL, + "email" TEXT, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT "oauth_pending_states_pkey" PRIMARY KEY ("id"), + CONSTRAINT "oauth_pending_states_state_id_key" UNIQUE ("state_id") +); + +-- Index for state_id lookup on callback (O(1) by state_id) +CREATE INDEX "oauth_pending_states_state_id_idx" ON "rebreak"."oauth_pending_states" ("state_id"); + +-- Index for cleanup of expired entries per user +CREATE INDEX "oauth_pending_states_created_at_idx" ON "rebreak"."oauth_pending_states" ("created_at"); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index f9306e9..84090bf 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -843,3 +843,25 @@ model ProtectedDevice { @@map("protected_devices") @@schema("rebreak") } + +/// Short-lived PKCE state entries for Microsoft OAuth flow. +/// Created by POST /api/mail/oauth/microsoft/init, consumed + deleted by +/// POST /api/mail/oauth/microsoft/callback. +/// TTL: 10 minutes — entries older than that are rejected and garbage-collected. +model OauthPendingState { + id String @id @default(uuid()) @db.Uuid + /// Random 128-bit hex string used as CSRF state parameter in auth URL. + stateId String @unique @map("state_id") + userId String @map("user_id") @db.Uuid + /// PKCE code_verifier (random 43-128 char string, S256 method). + /// Never leaves the server — only used for token exchange. + codeVerifier String @map("code_verifier") + /// Optional: pre-filled email for login_hint parameter. + email String? + createdAt DateTime @default(now()) @map("created_at") + + @@index([stateId]) + @@index([createdAt]) + @@map("oauth_pending_states") + @@schema("rebreak") +} diff --git a/backend/server/api/mail-connections/[id].delete.ts b/backend/server/api/mail-connections/[id].delete.ts index 4304726..fa52e9b 100644 --- a/backend/server/api/mail-connections/[id].delete.ts +++ b/backend/server/api/mail-connections/[id].delete.ts @@ -1,5 +1,5 @@ import { writeConsentRevoke } from "../../db/consent"; -import { deleteMailConnection } from "../../db/mail"; +import { deleteMailConnection, getDecryptedRefreshToken } from "../../db/mail"; import { usePrisma } from "../../utils/prisma"; /** @@ -70,15 +70,71 @@ export default defineEventHandler(async (event) => { userAgent, }); - // ── OAuth Token-Revoke (Placeholder für MS-OAuth-Phase) ────────────────── - // TODO (mo — Mail-Stack): Wenn authMethod === 'oauth2_microsoft': - // 1. oauthRefreshToken aus DB lesen (decrypt) - // 2. POST https://login.microsoftonline.com/common/oauth2/v2.0/logout - // mit grant_type=revoke, token=, client_id, client_secret - // 3. Max 3 Retries mit Exponential-Backoff - // 4. Audit-Log-Eintrag "token_revoked_at_ms: success/failure" - // 5. Trotzdem löschen wenn Revoke fehlschlägt (DSB-Memo Abschnitt 5.1) - // Tracking: consent-gap-plan.md TODO #2 + // ── 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). + } // ── DB-Row löschen ──────────────────────────────────────────────────────── await deleteMailConnection(user.id, connectionId); diff --git a/backend/server/api/mail/oauth/microsoft/callback.post.ts b/backend/server/api/mail/oauth/microsoft/callback.post.ts new file mode 100644 index 0000000..c1bf419 --- /dev/null +++ b/backend/server/api/mail/oauth/microsoft/callback.post.ts @@ -0,0 +1,178 @@ +/** + * POST /api/mail/oauth/microsoft/callback + * + * Step 2 of the Microsoft OAuth PKCE flow. + * + * Called by the native app after it intercepts the deep-link redirect from Microsoft + * (rebreak://auth/mail-oauth-callback?code=…&state=…). + * + * NO requireUser — the state parameter is the auth mechanism here (CSRF-safe because + * only the user who initiated the flow has the stateId in their session). The userId + * is retrieved from oauth_pending_states. + * + * Consent-Gate strategy (Hans-Müller-aligned): + * We set consent_at = now() inline during this callback, because the user has + * already passed through the ConnectMailSheet Art. 9 consent step before reaching + * the Outlook-OAuth button. The consent_version is stamped as CURRENT_ART9_MAIL_VERSION. + * This is the same pattern as [id].post.ts (password-based connect). + * + * Body: + * code: string — authorization code from Microsoft + * state: string — stateId from the init step (CSRF validation) + * + * Response: + * 200: { connectionId, email, provider: 'outlook_oauth' } + * 400: { error: 'invalid_body' } + * 401: { error: 'invalid_state' } — state not found, expired, or already used + * 500: { error: 'token_exchange_failed' } + */ +import { CURRENT_ART9_MAIL_VERSION } from "../../../../utils/consent-texts"; +import { writeConsentGrant, setMailConnectionConsent } from "../../../../db/consent"; +import { getProfile } from "../../../../db/profile"; +import { getPlanLimits } from "../../../../utils/plan-features"; + +export default defineEventHandler(async (event) => { + const config = useRuntimeConfig(event); + const clientId = config.msOauthClientId as string; + + if (!clientId) { + throw createError({ + statusCode: 500, + data: { error: "MS_OAUTH_CLIENT_ID not configured" }, + }); + } + + const body = await readBody(event).catch(() => null) as { + code?: string; + state?: string; + } | null; + + if (!body?.code || !body?.state) { + throw createError({ + statusCode: 400, + data: { error: "invalid_body", detail: "code and state are required" }, + }); + } + + const { code, state: stateId } = body; + + // ── Validate + consume state ────────────────────────────────────────────── + const pendingState = await consumeOauthPendingState(stateId); + + if (!pendingState) { + throw createError({ + statusCode: 401, + data: { + error: "invalid_state", + detail: "State not found, expired (>10 min), or already used", + }, + }); + } + + const { userId, codeVerifier, email: hintEmail } = pendingState; + + // ── Plan-Limit check ────────────────────────────────────────────────────── + const profile = await getProfile(userId); + const limits = getPlanLimits(profile?.plan ?? "free"); + + if (limits.mailAgents !== Infinity) { + const count = await countMailConnections(userId); + if (count >= limits.mailAgents) { + throw createError({ + statusCode: 403, + data: { + error: "plan_limit", + resource: "mail_accounts", + current: count, + limit: limits.mailAgents, + }, + }); + } + } + + // ── Token Exchange ──────────────────────────────────────────────────────── + let tokenResponse; + try { + tokenResponse = await exchangeCodeForTokens({ + clientId, + code, + codeVerifier, + }); + } catch (err: any) { + throw createError({ + statusCode: 502, + data: { + error: "token_exchange_failed", + detail: err.message ?? "microsoft_error", + }, + }); + } + + const { access_token, refresh_token, expires_in, scope, id_token } = tokenResponse; + + // ── Extract email from ID-token ─────────────────────────────────────────── + let outlookEmail: string | null = hintEmail ?? null; + if (id_token) { + const extracted = extractEmailFromIdToken(id_token); + if (extracted) outlookEmail = extracted; + } + + if (!outlookEmail) { + throw createError({ + statusCode: 502, + data: { + error: "email_extraction_failed", + detail: "Could not extract email from Microsoft ID-token. Ensure openid+User.Read scopes are granted.", + }, + }); + } + + // ── Encrypt tokens ──────────────────────────────────────────────────────── + const encryptedAccessToken = encrypt(access_token); + const encryptedRefreshToken = encrypt(refresh_token); + const tokenExpiry = new Date(Date.now() + expires_in * 1000); + + // ── Consent setup ───────────────────────────────────────────────────────── + 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; + + // ── Upsert MailConnection ───────────────────────────────────────────────── + const connection = await upsertOauthMicrosoftConnection({ + userId, + email: outlookEmail, + encryptedAccessToken, + encryptedRefreshToken, + tokenExpiry, + scope: scope ?? MS_OAUTH_SCOPES, + }); + + // ── Consent stamp + audit log ───────────────────────────────────────────── + await setMailConnectionConsent({ + connectionId: connection.id, + userId, + consentAt: now, + consentVersion: CURRENT_ART9_MAIL_VERSION, + consentIpAddress: ipAddress, + }); + + await writeConsentGrant({ + userId, + consentType: "art9-mail", + consentVersion: CURRENT_ART9_MAIL_VERSION, + consentAt: now, + ipAddress, + userAgent, + mailConnectionId: connection.id, + }); + + return { + connectionId: connection.id, + email: outlookEmail, + provider: "outlook_oauth", + title: null, + }; +}); diff --git a/backend/server/api/mail/oauth/microsoft/init.post.ts b/backend/server/api/mail/oauth/microsoft/init.post.ts new file mode 100644 index 0000000..1c358d4 --- /dev/null +++ b/backend/server/api/mail/oauth/microsoft/init.post.ts @@ -0,0 +1,71 @@ +/** + * POST /api/mail/oauth/microsoft/init + * + * Step 1 of the Microsoft OAuth PKCE flow. + * Generates a PKCE code_verifier + code_challenge, persists the state in + * oauth_pending_states (TTL 10 min), and returns the authorization URL. + * + * The native app opens this URL via expo-web-browser (WebBrowser.openAuthSessionAsync). + * After login + consent, Microsoft redirects to rebreak://auth/mail-oauth-callback?code=…&state=… + * The app deep-link handler calls POST /api/mail/oauth/microsoft/callback with { code, state }. + * + * Body (optional): + * email?: string — pre-fills login_hint + prompt logic + * + * Response: + * 200: { authorizationUrl: string } + * 401: not authenticated + */ +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + const config = useRuntimeConfig(event); + const clientId = config.msOauthClientId as string; + + if (!clientId) { + throw createError({ + statusCode: 500, + data: { error: "MS_OAUTH_CLIENT_ID not configured" }, + }); + } + + const body = await readBody(event).catch(() => ({})) as { email?: string }; + const hintEmail = body?.email?.trim() || null; + + // ── PKCE ───────────────────────────────────────────────────────────────── + const codeVerifier = generateCodeVerifier(); + const codeChallenge = computeCodeChallenge(codeVerifier); + const stateId = generateStateId(); + + // ── Persist state (TTL enforced at read time in callback) ───────────────── + // Clean up stale entries for this user (older than 10 min) before inserting. + await createOauthPendingState({ + stateId, + userId: user.id, + codeVerifier, + email: hintEmail, + }); + + // ── Build authorization URL ─────────────────────────────────────────────── + const params = new URLSearchParams({ + client_id: clientId, + response_type: "code", + redirect_uri: MS_REDIRECT_URI, + response_mode: "query", + scope: MS_OAUTH_SCOPES, + state: stateId, + code_challenge: codeChallenge, + code_challenge_method: "S256", + // prompt=consent: always show consent screen so user sees what scopes are requested. + // Alternative: 'select_account' for re-connect flows (less friction but skips + // scope confirmation). Using 'consent' is the safer DSGVO-aligned default. + prompt: "consent", + }); + + if (hintEmail) { + params.set("login_hint", hintEmail); + } + + const authorizationUrl = `${MS_AUTH_BASE}?${params.toString()}`; + + return { authorizationUrl }; +}); diff --git a/backend/server/api/mail/scan-internal.post.ts b/backend/server/api/mail/scan-internal.post.ts index 4dba53f..f1b61d7 100644 --- a/backend/server/api/mail/scan-internal.post.ts +++ b/backend/server/api/mail/scan-internal.post.ts @@ -34,7 +34,22 @@ export default defineEventHandler(async (event) => { throw createError({ statusCode: 400, message: "userId missing" }); const connections = await getMailConnections(userId); - if (connections.length === 0) return { scanned: 0, blocked: 0 }; + if (connections.length === 0) return { ok: true, scanned: 0, blocked: 0, skippedNoConsent: 0 }; + + // Consent-Gate (DSGVO Art. 9): Cron ist NICHT user-initiiert — Art. 9-Daten dürfen + // ohne explizite Einwilligung nicht verarbeitet werden. Connections ohne consent_at überspringen. + const skippedNoConsent = connections.filter((c) => !c.consentAt).length; + const eligibleConnections = connections.filter((c) => c.consentAt); + + if (skippedNoConsent > 0) { + console.log( + `[scan-internal] skipping ${skippedNoConsent} connections for userId=${userId} — no consent_at (pending re-consent)`, + ); + } + + if (eligibleConnections.length === 0) { + return { ok: true, scanned: 0, blocked: 0, skippedNoConsent }; + } // Plan-aware blocklist // Grace-Period: wenn globalBlocklistGraceUntil noch in der Zukunft liegt, @@ -51,7 +66,7 @@ export default defineEventHandler(async (event) => { let totalScanned = 0; let totalBlocked = 0; - for (const connection of connections) { + for (const connection of eligibleConnections) { let password: string; try { password = decrypt(connection.passwordEncrypted); @@ -220,5 +235,5 @@ export default defineEventHandler(async (event) => { ); } - return { scanned: totalScanned, blocked: totalBlocked }; + return { ok: true, scanned: totalScanned, blocked: totalBlocked, skippedNoConsent }; }); diff --git a/backend/server/api/mail/scan.post.ts b/backend/server/api/mail/scan.post.ts index 51db7d4..d758123 100644 --- a/backend/server/api/mail/scan.post.ts +++ b/backend/server/api/mail/scan.post.ts @@ -32,6 +32,16 @@ export default defineEventHandler(async (event) => { }); } + // Consent-Gate (DSGVO Art. 9): Connections ohne explizite Einwilligung überspringen + const skippedNoConsent = connections.filter((c) => !c.consentAt).length; + const eligibleConnections = connections.filter((c) => c.consentAt); + + if (skippedNoConsent > 0) { + console.log( + `[scan] skipping ${skippedNoConsent} connections — no consent_at (pending re-consent)`, + ); + } + // Plan-aware: Free users get only custom domains, Pro/Legend get global blocklist const profile = await getProfile(user.id); const limits = getPlanLimits(profile?.plan ?? "free"); @@ -46,7 +56,7 @@ export default defineEventHandler(async (event) => { let totalScanned = 0; let totalBlocked = 0; - for (const connection of connections) { + for (const connection of eligibleConnections) { let password: string; try { password = decrypt(connection.passwordEncrypted); @@ -210,5 +220,5 @@ export default defineEventHandler(async (event) => { ); } - return { scanned: totalScanned, blocked: totalBlocked }; + return { ok: true, scanned: totalScanned, blocked: totalBlocked, skippedNoConsent }; }); diff --git a/backend/server/db/mail.ts b/backend/server/db/mail.ts index e023f7a..ff8a762 100644 --- a/backend/server/db/mail.ts +++ b/backend/server/db/mail.ts @@ -1,4 +1,6 @@ import { usePrisma } from "../utils/prisma"; +import { encrypt, decrypt } from "../utils/crypto"; +import { refreshMicrosoftTokens } from "../utils/ms-oauth"; export async function getMailConnections(userId: string) { const db = usePrisma(); @@ -396,3 +398,237 @@ export async function getBlockedMailsByConnection(userId: string) { }; }); } + +// ─── OAuth Pending States ───────────────────────────────────────────────────── + +const OAUTH_STATE_TTL_MS = 10 * 60 * 1000; // 10 minutes + +/** + * Creates a new OAuth pending state entry for PKCE flow. + * Also garbage-collects expired states for this user (clean-on-write). + */ +export async function createOauthPendingState(params: { + stateId: string; + userId: string; + codeVerifier: string; + email: string | null; +}) { + const db = usePrisma(); + + // Garbage-collect stale states for this user + const cutoff = new Date(Date.now() - OAUTH_STATE_TTL_MS); + await db.oauthPendingState.deleteMany({ + where: { userId: params.userId, createdAt: { lt: cutoff } }, + }); + + return db.oauthPendingState.create({ + data: { + stateId: params.stateId, + userId: params.userId, + codeVerifier: params.codeVerifier, + email: params.email, + }, + }); +} + +/** + * Consumes an OAuth pending state: validates it exists + not expired, then deletes it. + * Returns null if not found or expired (caller should 401). + * This is atomic enough for our use-case (state is single-use, mobile client is single-threaded). + */ +export async function consumeOauthPendingState(stateId: string): Promise<{ + userId: string; + codeVerifier: string; + email: string | null; +} | null> { + const db = usePrisma(); + + const entry = await db.oauthPendingState.findUnique({ + where: { stateId }, + select: { id: true, userId: true, codeVerifier: true, email: true, createdAt: true }, + }); + + if (!entry) return null; + + // Check TTL + const age = Date.now() - entry.createdAt.getTime(); + if (age > OAUTH_STATE_TTL_MS) { + // Expired — clean up and reject + await db.oauthPendingState.delete({ where: { id: entry.id } }).catch(() => {}); + return null; + } + + // Consume (delete) — single-use + await db.oauthPendingState.delete({ where: { id: entry.id } }).catch(() => {}); + + return { + userId: entry.userId, + codeVerifier: entry.codeVerifier, + email: entry.email, + }; +} + +// ─── OAuth MailConnection Upsert ────────────────────────────────────────────── + +/** + * Creates or updates a MailConnection for Microsoft OAuth. + * Uses userId+email as the unique key (same as password-based connections). + * passwordEncrypted is set to "" (empty) — not used for oauth connections. + * authMethod='oauth2_microsoft' is the discriminator. + */ +export async function upsertOauthMicrosoftConnection(params: { + userId: string; + email: string; + encryptedAccessToken: string; + encryptedRefreshToken: string; + tokenExpiry: Date; + scope: string; +}) { + const db = usePrisma(); + + return db.mailConnection.upsert({ + where: { userId_email: { userId: params.userId, email: params.email } }, + create: { + userId: params.userId, + email: params.email, + provider: "imap", + providerName: "Outlook", + imapHost: "outlook.office365.com", + imapPort: 993, + passwordEncrypted: "", // not used for oauth + rejectUnauthorized: true, + useStarttls: false, + isActive: true, + authMethod: "oauth2_microsoft", + oauthAccessToken: params.encryptedAccessToken, + oauthRefreshToken: params.encryptedRefreshToken, + oauthTokenExpiry: params.tokenExpiry, + oauthScope: params.scope, + }, + update: { + providerName: "Outlook", + imapHost: "outlook.office365.com", + imapPort: 993, + authMethod: "oauth2_microsoft", + oauthAccessToken: params.encryptedAccessToken, + oauthRefreshToken: params.encryptedRefreshToken, + oauthTokenExpiry: params.tokenExpiry, + oauthScope: params.scope, + isActive: true, + // Clear error state from a previous failed connection attempt + lastConnectError: null, + lastConnectErrorAt: null, + }, + }); +} + +// ─── Token Refresh with Race-Condition Protection ───────────────────────────── + +/** + * Refreshes the Microsoft OAuth tokens for a given MailConnection and persists them. + * + * Race-Condition strategy (Optimistic Concurrency): + * 1. Read current oauth_token_expiry from DB. + * 2. POST to MS token endpoint to get fresh tokens. + * 3. UPDATE with WHERE oauth_token_expiry = (optimistic lock). + * 4. If affected_rows = 0: another process refreshed concurrently. + * → Read the freshly stored access_token and return it WITHOUT re-refreshing. + * This avoids a double-refresh loop that would invalidate the new refresh_token. + * + * Returns: decrypted (plaintext) access_token ready for IMAP XOAUTH2 use. + * + * Throws if: + * - Connection not found or not oauth2_microsoft + * - MS token refresh fails (invalid/revoked refresh_token) + */ +export async function refreshAndSaveTokens( + connectionId: string, + clientId: string, +): Promise { + const db = usePrisma(); + + // Step 1: Read current token state + const conn = await db.mailConnection.findFirst({ + where: { id: connectionId, authMethod: "oauth2_microsoft" }, + select: { + oauthRefreshToken: true, + oauthAccessToken: true, + oauthTokenExpiry: true, + }, + }); + + if (!conn?.oauthRefreshToken) { + throw new Error(`Connection ${connectionId} has no oauth refresh_token — cannot refresh`); + } + + const currentExpiry = conn.oauthTokenExpiry; + const decryptedRefreshToken = decrypt(conn.oauthRefreshToken); + + // Step 2: Refresh at MS + const fresh = await refreshMicrosoftTokens({ + clientId, + refreshToken: decryptedRefreshToken, + }); + + const newExpiry = new Date(Date.now() + fresh.expires_in * 1000); + const encryptedNewAccess = encrypt(fresh.access_token); + const encryptedNewRefresh = encrypt(fresh.refresh_token); + + // Step 3: Optimistic update — only update if expiry hasn't changed since we read + // Using $executeRaw for the WHERE-with-timestamp comparison (Prisma updateMany + // doesn't support "affected rows" count in a useful way here). + const result = await db.$executeRaw` + UPDATE "rebreak"."mail_connections" + SET + "oauth_access_token" = ${encryptedNewAccess}, + "oauth_refresh_token" = ${encryptedNewRefresh}, + "oauth_token_expiry" = ${newExpiry} + WHERE + "id" = ${connectionId}::uuid + AND ( + "oauth_token_expiry" IS NOT DISTINCT FROM ${currentExpiry}::timestamptz + ) + `; + + if (result === 0) { + // Step 4: Another process refreshed concurrently — read the fresh token they stored + // and return it. Do NOT refresh again (would invalidate their new refresh_token). + const updated = await db.mailConnection.findFirst({ + where: { id: connectionId }, + select: { oauthAccessToken: true }, + }); + + if (!updated?.oauthAccessToken) { + throw new Error(`Concurrent refresh detected for ${connectionId} but no token found`); + } + + return decrypt(updated.oauthAccessToken); + } + + // Normal path: we won the race, return the token we just stored + return fresh.access_token; +} + +/** + * Gets the decrypted refresh_token for a MailConnection. + * Used by [id].delete.ts for the revoke flow. + * Returns null if no refresh_token is stored. + */ +export async function getDecryptedRefreshToken( + connectionId: string, + userId: string, +): Promise { + const db = usePrisma(); + const conn = await db.mailConnection.findFirst({ + where: { id: connectionId, userId, authMethod: "oauth2_microsoft" }, + select: { oauthRefreshToken: true }, + }); + + if (!conn?.oauthRefreshToken) return null; + + try { + return decrypt(conn.oauthRefreshToken); + } catch { + return null; + } +} diff --git a/backend/server/utils/ms-oauth.ts b/backend/server/utils/ms-oauth.ts new file mode 100644 index 0000000..054e833 --- /dev/null +++ b/backend/server/utils/ms-oauth.ts @@ -0,0 +1,180 @@ +/** + * Microsoft Identity Platform — OAuth2 PKCE utilities. + * + * Tenant: 'common' — covers consumer Outlook/Hotmail/Live/MSN accounts + * AND Microsoft 365 work/school accounts. Decision: common > consumers because + * the Azure App Registration was created as Multi-Tenant + Personal Accounts. + * + * Public Client / No Client-Secret — PKCE (S256) is the security mechanism. + * Microsoft explicitly supports PKCE without client_secret for public clients + * (mobile/native apps). See: https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-auth-code-flow + */ + +import { createHash, randomBytes } from "crypto"; + +const MS_TENANT = "common"; +const MS_TOKEN_ENDPOINT = `https://login.microsoftonline.com/${MS_TENANT}/oauth2/v2.0/token`; +export const MS_AUTH_BASE = `https://login.microsoftonline.com/${MS_TENANT}/oauth2/v2.0/authorize`; + +/** + * OAuth scopes requested. + * Matches the DSGVO-Memo Section 4.3 (Datenminimierung). + * User.Read is included per User decision (email extraction from ID-token). + * Hans-Müller-Memo will document this in the next revision. + */ +export const MS_OAUTH_SCOPES = [ + "https://outlook.office.com/IMAP.AccessAsUser.All", + "offline_access", + "openid", + "User.Read", +].join(" "); + +/** + * The redirect_uri registered in the Azure App Registration for the native client. + * Must match exactly what the client sends — scheme registered in app.json. + */ +export const MS_REDIRECT_URI = "rebreak://auth/mail-oauth-callback"; + +// ── PKCE Helpers ───────────────────────────────────────────────────────────── + +/** Generates a cryptographically random code_verifier (43-128 chars, URL-safe). */ +export function generateCodeVerifier(): string { + // 96 random bytes → 128 base64url chars (well within PKCE 43-128 range) + return randomBytes(96).toString("base64url"); +} + +/** Computes S256 code_challenge from the verifier. */ +export function computeCodeChallenge(verifier: string): string { + return createHash("sha256").update(verifier).digest("base64url"); +} + +/** Generates a random state ID for CSRF protection (hex, 32 chars = 128 bits). */ +export function generateStateId(): string { + return randomBytes(16).toString("hex"); +} + +// ── Token Exchange ──────────────────────────────────────────────────────────── + +export interface MicrosoftTokenResponse { + access_token: string; + /** Microsoft ALWAYS returns a refresh_token when offline_access scope is included. */ + refresh_token: string; + /** Seconds until access_token expires (typically 3600 for MS). */ + expires_in: number; + scope: string; + token_type: string; + /** JWT containing user claims (email, sub, etc.). Returned because openid scope is included. */ + id_token?: string; +} + +/** + * Exchanges an authorization_code for tokens. + * Called from callback endpoint after state validation. + */ +export async function exchangeCodeForTokens(params: { + clientId: string; + code: string; + codeVerifier: string; +}): Promise { + const body = new URLSearchParams({ + grant_type: "authorization_code", + client_id: params.clientId, + code: params.code, + redirect_uri: MS_REDIRECT_URI, + code_verifier: params.codeVerifier, + scope: MS_OAUTH_SCOPES, + }); + + const res = await fetch(MS_TOKEN_ENDPOINT, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: body.toString(), + }); + + if (!res.ok) { + const errText = await res.text().catch(() => "unknown error"); + throw new Error(`MS token exchange failed (${res.status}): ${errText}`); + } + + const data = await res.json() as MicrosoftTokenResponse; + + if (!data.access_token || !data.refresh_token) { + throw new Error("MS token response missing access_token or refresh_token"); + } + + return data; +} + +/** + * Refreshes a Microsoft access_token using a refresh_token. + * + * CRITICAL: Microsoft rotates refresh_tokens — the response may contain a NEW + * refresh_token that invalidates the old one. Always persist the new refresh_token + * if present, otherwise the next refresh will fail with AADSTS70043. + */ +export async function refreshMicrosoftTokens(params: { + clientId: string; + refreshToken: string; +}): Promise<{ + access_token: string; + /** May be a NEW token (MS refresh token rotation). Persist this immediately. */ + refresh_token: string; + expires_in: number; +}> { + const body = new URLSearchParams({ + grant_type: "refresh_token", + client_id: params.clientId, + refresh_token: params.refreshToken, + scope: MS_OAUTH_SCOPES, + }); + + const res = await fetch(MS_TOKEN_ENDPOINT, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: body.toString(), + }); + + if (!res.ok) { + const errText = await res.text().catch(() => "unknown error"); + throw new Error(`MS token refresh failed (${res.status}): ${errText}`); + } + + const data = await res.json() as MicrosoftTokenResponse; + + if (!data.access_token) { + throw new Error("MS refresh response missing access_token"); + } + + return { + access_token: data.access_token, + // MS may omit refresh_token if it didn't rotate — fall back to original + refresh_token: data.refresh_token ?? params.refreshToken, + expires_in: data.expires_in, + }; +} + +/** + * Extracts the email claim from a Microsoft ID-token (JWT). + * The ID-token is a standard JWT — we only need the payload, no signature + * verification required here because: + * 1. We received it directly from Microsoft's token endpoint over HTTPS. + * 2. We don't rely on it for auth decisions — just for extracting the email + * to store in mail_connections.email. + * + * Claims checked (in order): email → preferred_username → upn + */ +export function extractEmailFromIdToken(idToken: string): string | null { + try { + const [, payloadB64] = idToken.split("."); + if (!payloadB64) return null; + const payload = JSON.parse(Buffer.from(payloadB64, "base64url").toString("utf8")); + return ( + payload.email ?? + payload.preferred_username ?? + payload.upn ?? + null + ); + } catch { + return null; + } +}