feat(mail): outlook oauth — full end-to-end (backend + daemon + frontend)

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 = <gelesener-wert>,
  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) <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-05-13 21:04:14 +02:00
parent 275637f0b0
commit fc69a14f25
20 changed files with 1709 additions and 118 deletions

View File

@ -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<string, string> = { free: 'Free', pro: 'Pro', legend: 'Legend' };
@ -97,6 +99,9 @@ export default function MailScreen() {
const [disconnectingId, setDisconnectingId] = useState<string | null>(null);
const [expandedAccount, setExpandedAccount] = useState<string | null>(null);
const [activityLogExpanded, setActivityLogExpanded] = useState(false);
const [oauthTitleSheetConnectionId, setOauthTitleSheetConnectionId] = useState<string | null>(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 && (
<EditMailTitleSheet
visible={!!oauthTitleSheetConnectionId}
connectionId={oauthTitleSheetConnectionId}
currentTitle={null}
onClose={() => { setOauthTitleSheetConnectionId(null); setSuccessVisible(true); }}
onSuccess={() => { setOauthTitleSheetConnectionId(null); setSuccessVisible(true); refresh(); }}
/>
)}
<SuccessAlert
visible={successVisible}
title={t('mail.connect_success_title')}

View File

@ -0,0 +1,33 @@
// Deep-Link Bridge für Microsoft OAuth Mail-Callback.
//
// rebreak://auth/mail-oauth-callback?code=...&state=...
//
// Auf iOS: WebBrowser.openAuthSessionAsync fängt den Redirect selbst ab und
// gibt result.url direkt zurück — diese Seite wird auf iOS nie gerendert.
//
// Auf Android: das System öffnet die App via Deep-Link, expo-router rendert
// diese Route bevor openAuthSessionAsync's Listener feuern kann. Diese
// Bridge-Page stellt sicher dass kein 404-Flash erscheint. Der eigentliche
// Code/State-Austausch läuft im ConnectMailSheet-Handler.
import { useEffect } from 'react';
import { View, ActivityIndicator } from 'react-native';
import { useRouter } from 'expo-router';
import { useColors } from '../../lib/theme';
export default function MailOAuthCallback() {
const router = useRouter();
const colors = useColors();
useEffect(() => {
const timer = setTimeout(() => {
router.replace('/(app)' as never);
}, 80);
return () => clearTimeout(timer);
}, []);
return (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.bg }}>
<ActivityIndicator size="large" color="#0078D4" />
</View>
);
}

View File

@ -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<string | null>(null);
const [fieldsComplete, setFieldsComplete] = useState(false);
const [oauthRunning, setOauthRunning] = useState(false);
const [oauthError, setOauthError] = useState<string | null>(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 (
<FormSheet
@ -220,6 +287,16 @@ export function ConnectMailSheet({ visible, onClose, onSuccess }: Props) {
/>
) : view === 'grid' ? (
<ProviderGrid providers={PROVIDERS} onSelect={handleProviderSelect} t={t} />
) : view === 'oauth_warning' ? (
<OAuthWarningStep
error={oauthError}
onContinue={handleOAuthStart}
onCancel={() => setView('grid')}
t={t}
colors={colors}
/>
) : view === 'oauth_pending' ? (
<OAuthPendingStep t={t} colors={colors} />
) : (
<SheetFieldStack
fields={[
@ -539,6 +616,155 @@ function ConsentStep({
);
}
// ---------------------------------------------------------------------------
// Sub-View: OAuth Warning (DSB Memo Section 6.1 — Outing-Effekt-Hinweis)
// ---------------------------------------------------------------------------
function OAuthWarningStep({
error,
onContinue,
onCancel,
t,
colors,
}: {
error: string | null;
onContinue: () => void;
onCancel: () => void;
t: (key: string) => string;
colors: ReturnType<typeof useColors>;
}) {
return (
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{ padding: 20, gap: 16 }}
showsVerticalScrollIndicator={false}
>
<View
style={{
backgroundColor: '#fffbeb',
borderRadius: 12,
borderWidth: 1,
borderColor: '#fde68a',
padding: 14,
gap: 10,
}}
>
<View style={{ flexDirection: 'row', alignItems: 'flex-start', gap: 10 }}>
<Ionicons name="information-circle-outline" size={20} color="#92400e" style={{ marginTop: 1 }} />
<Text
style={{
flex: 1,
fontSize: 13,
fontFamily: 'Nunito_700Bold',
color: '#92400e',
}}
>
{t('mail.oauth.warning_title')}
</Text>
</View>
<Text
style={{
fontSize: 13,
fontFamily: 'Nunito_400Regular',
color: '#78350f',
lineHeight: 19,
}}
>
{t('mail.oauth.warning_body')}
</Text>
</View>
{error ? (
<Text
style={{
fontSize: 13,
fontFamily: 'Nunito_400Regular',
color: '#dc2626',
textAlign: 'center',
}}
>
{error}
</Text>
) : null}
<TouchableOpacity
activeOpacity={0.85}
onPress={onContinue}
style={{ marginTop: 4 }}
>
<View
style={{
backgroundColor: '#0078D4',
borderRadius: 14,
paddingVertical: 14,
alignItems: 'center',
}}
>
<Text style={{ fontSize: 15, fontFamily: 'Nunito_700Bold', color: '#fff' }}>
{t('mail.oauth.warning_continue')}
</Text>
</View>
</TouchableOpacity>
<TouchableOpacity
activeOpacity={0.7}
onPress={onCancel}
style={{ marginBottom: 12, alignItems: 'center', paddingVertical: 10 }}
>
<Text
style={{
fontSize: 14,
fontFamily: 'Nunito_600SemiBold',
color: colors.textMuted,
}}
>
{t('mail.oauth.warning_cancel')}
</Text>
</TouchableOpacity>
</ScrollView>
);
}
// ---------------------------------------------------------------------------
// Sub-View: OAuth Pending (Browser läuft gerade)
// ---------------------------------------------------------------------------
function OAuthPendingStep({
t,
colors,
}: {
t: (key: string) => string;
colors: ReturnType<typeof useColors>;
}) {
return (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', padding: 32, gap: 16 }}>
<ActivityIndicator size="large" color="#0078D4" />
<Text
style={{
fontSize: 15,
fontFamily: 'Nunito_600SemiBold',
color: colors.text,
textAlign: 'center',
lineHeight: 22,
}}
>
{t('mail.oauth.pending_label')}
</Text>
<Text
style={{
fontSize: 13,
fontFamily: 'Nunito_400Regular',
color: colors.textMuted,
textAlign: 'center',
lineHeight: 19,
}}
>
{t('mail.oauth.pending_hint')}
</Text>
</View>
);
}
// ---------------------------------------------------------------------------
// Sub-View: Provider-Grid
// ---------------------------------------------------------------------------

View File

@ -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 (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<TouchableOpacity
activeOpacity={1}
onPress={onClose}
style={{
flex: 1,
backgroundColor: 'rgba(0,0,0,0.35)',
justifyContent: 'center',
alignItems: 'center',
padding: 24,
}}
>
<TouchableOpacity activeOpacity={1} onPress={() => {}} style={{ width: '88%', maxWidth: 340 }}>
<View
style={{
backgroundColor: '#fff',
borderRadius: 22,
padding: 22,
gap: 14,
shadowColor: '#000',
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.18,
shadowRadius: 24,
elevation: 16,
}}
>
<View
style={{
width: 52,
height: 52,
borderRadius: 26,
backgroundColor: '#16a34a',
alignItems: 'center',
justifyContent: 'center',
alignSelf: 'center',
}}
>
<Ionicons name="checkmark" size={28} color="#fff" />
</View>
<Text
style={{
fontSize: 17,
fontFamily: 'Nunito_700Bold',
color: '#0a0a0a',
textAlign: 'center',
}}
>
{t('mail.oauth.disconnect_hint_title')}
</Text>
<Text
style={{
fontSize: 13,
fontFamily: 'Nunito_400Regular',
color: '#525252',
lineHeight: 19,
textAlign: 'center',
}}
>
{t('mail.oauth.disconnect_hint_body')}
</Text>
<View style={{ flexDirection: 'row', gap: 10 }}>
<TouchableOpacity
activeOpacity={0.7}
onPress={() => Linking.openURL('https://account.microsoft.com/consent').catch(() => {})}
style={{
flex: 1,
paddingVertical: 10,
borderRadius: 10,
backgroundColor: '#eff6ff',
borderWidth: 1,
borderColor: '#bfdbfe',
alignItems: 'center',
}}
>
<Text style={{ fontSize: 13, fontFamily: 'Nunito_700Bold', color: '#007AFF' }}>
{t('mail.oauth.disconnect_hint_open_ms')}
</Text>
</TouchableOpacity>
<TouchableOpacity
activeOpacity={0.7}
onPress={onClose}
style={{
flex: 1,
paddingVertical: 10,
borderRadius: 10,
backgroundColor: '#f5f5f5',
alignItems: 'center',
}}
>
<Text style={{ fontSize: 13, fontFamily: 'Nunito_700Bold', color: '#0a0a0a' }}>
OK
</Text>
</TouchableOpacity>
</View>
</View>
</TouchableOpacity>
</TouchableOpacity>
</Modal>
);
}
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<string | null>(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}
/>
<SettingsRow
icon="key-outline"
label={t('mail.row_password')}
value="••••••••"
onPress={() => setEditPasswordVisible(true)}
/>
{!isOAuth && (
<SettingsRow
icon="key-outline"
label={t('mail.row_password')}
value="••••••••"
onPress={() => setEditPasswordVisible(true)}
/>
)}
<TouchableOpacity
activeOpacity={0.7}
@ -654,17 +779,26 @@ export function MailAccountCard({
onConfirm={async () => {
setConfirmVisible(false);
await onDisconnect(account.id);
if (isOAuth) setOauthDisconnectHintVisible(true);
}}
onCancel={() => setConfirmVisible(false)}
/>
<EditMailAccountSheet
visible={editPasswordVisible}
email={account.email}
onClose={() => setEditPasswordVisible(false)}
onSuccess={onEditSuccess}
<OAuthDisconnectHintModal
visible={oauthDisconnectHintVisible}
onClose={() => setOauthDisconnectHintVisible(false)}
t={t}
/>
{!isOAuth && (
<EditMailAccountSheet
visible={editPasswordVisible}
email={account.email}
onClose={() => setEditPasswordVisible(false)}
onSuccess={onEditSuccess}
/>
)}
<EditMailTitleSheet
visible={editTitleVisible}
connectionId={account.id}

View File

@ -461,6 +461,20 @@
},
"filter": {
"all": "Alle"
},
"oauth": {
"warning_title": "Hinweis zur Sichtbarkeit in deinem Microsoft-Konto",
"warning_body": "Microsoft zeigt dir gleich einen Berechtigungsdialog. Der App-Name \"Rebreak\" erscheint dort und wird in deiner Microsoft-Konto-Übersicht unter App-Berechtigungen sichtbar. Falls dein Microsoft-Konto von anderen Personen mitgenutzt wird, solltest du das berücksichtigen.",
"warning_continue": "Verstanden, mit Microsoft anmelden",
"warning_cancel": "Zurück zur Auswahl",
"pending_label": "Microsoft-Anmeldung läuft …",
"pending_hint": "Schließe den Schritt in deinem Browser ab und kehre dann zurück.",
"error_aborted": "Anmeldung abgebrochen.",
"error_no_code": "OAuth fehlgeschlagen — kein Autorisierungscode zurück.",
"error_callback_failed": "Verbindung konnte nicht abgeschlossen werden. Bitte versuche es erneut.",
"disconnect_hint_title": "Verbindung getrennt",
"disconnect_hint_body": "Die Tokens wurden aus unserer Datenbank gelöscht. Microsoft unterstützt leider keinen serverseitigen Widerruf durch Drittanbieter-Apps. Für eine vollständige Entfernung der Rebreak-Berechtigung in deinem Microsoft-Konto: account.microsoft.com → Sicherheit → Berechtigungen für Apps → Rebreak suchen → Entfernen.",
"disconnect_hint_open_ms": "Microsoft öffnen"
}
},
"settings": {

View File

@ -461,6 +461,20 @@
},
"filter": {
"all": "All"
},
"oauth": {
"warning_title": "Note on visibility in your Microsoft account",
"warning_body": "Microsoft will show you a permission dialog. The app name \"Rebreak\" will appear there and will be visible in your Microsoft account overview under App permissions. If your Microsoft account is shared with others, you should take this into account.",
"warning_continue": "Understood, sign in with Microsoft",
"warning_cancel": "Back to selection",
"pending_label": "Microsoft sign-in in progress…",
"pending_hint": "Complete the step in your browser and then return here.",
"error_aborted": "Sign-in cancelled.",
"error_no_code": "OAuth failed — no authorization code received.",
"error_callback_failed": "Connection could not be completed. Please try again.",
"disconnect_hint_title": "Connection removed",
"disconnect_hint_body": "The tokens have been deleted from our database. Unfortunately Microsoft does not support server-side revocation by third-party apps. To fully remove Rebreak's permission from your Microsoft account: account.microsoft.com → Security → App permissions → find Rebreak → Remove.",
"disconnect_hint_open_ms": "Open Microsoft"
}
},
"settings": {

View File

@ -10,32 +10,37 @@ type ProviderSnapshot = {
guideUrl: string;
disabled?: boolean;
disabledLabelKey?: string;
authMethod?: 'imap' | 'oauth_microsoft';
};
type MailConnectDraftState = {
view: 'consent' | 'grid' | 'form';
view: 'consent' | 'grid' | 'form' | 'oauth_warning' | 'oauth_pending';
consentGiven: boolean;
selectedProvider: ProviderSnapshot | null;
email: string;
title: string;
/** Set after a successful OAuth callback so the Title-Edit sheet can open in the parent. */
pendingOAuthConnectionId: string | null;
setView: (view: 'consent' | 'grid' | 'form') => 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<MailConnectDraftState>((set) => ({
@ -46,5 +51,6 @@ export const useMailConnectDraft = create<MailConnectDraftState>((set) => ({
setSelectedProvider: (selectedProvider) => set({ selectedProvider }),
setEmail: (email) => set({ email }),
setTitle: (title) => set({ title }),
setPendingOAuthConnectionId: (pendingOAuthConnectionId) => set({ pendingOAuthConnectionId }),
reset: () => set(INITIAL),
}));

View File

@ -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)

View File

@ -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 = <gelesener Wert>
* 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<connectionId, SessionHandle>
@ -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 <base64-token>
// { 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);

View File

@ -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"'
'

View File

@ -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 ?? "",

View File

@ -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");

View File

@ -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")
}

View File

@ -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=<refresh_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);

View File

@ -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,
};
});

View File

@ -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 };
});

View File

@ -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 };
});

View File

@ -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 };
});

View File

@ -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 = <read value> (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<string> {
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<string | null> {
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;
}
}

View File

@ -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<MicrosoftTokenResponse> {
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;
}
}