diff --git a/apps/rebreak-native/app/index.tsx b/apps/rebreak-native/app/index.tsx index 301e86d..5801180 100644 --- a/apps/rebreak-native/app/index.tsx +++ b/apps/rebreak-native/app/index.tsx @@ -4,6 +4,7 @@ import Svg, { Defs, RadialGradient, Rect, Stop } from 'react-native-svg'; import { useRouter } from 'expo-router'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useTranslation } from 'react-i18next'; +import { useAuthStore } from '../stores/auth'; const { width: SW, height: SH } = Dimensions.get('window'); @@ -12,6 +13,19 @@ export default function LandingScreen() { const insets = useSafeAreaInsets(); const { t } = useTranslation(); + // Reaktiver Routing-Fix für „eingeloggt bleiben": wenn beim Cold-Start (oder + // nach einem `router.replace('/')` aus dem LockScreen-Sign-Out) bereits eine + // gültige Session in AsyncStorage liegt, überspringen wir das Landing und + // schicken den User direkt in `(app)`. + const session = useAuthStore((s) => s.session); + const loading = useAuthStore((s) => s.loading); + + useEffect(() => { + if (!loading && session) { + router.replace('/(app)'); + } + }, [loading, session, router]); + const glowTopOpacity = useRef(new Animated.Value(0.5)).current; const glowCenterOpacity = useRef(new Animated.Value(0)).current; const glowCenterScale = useRef(new Animated.Value(0.6)).current; @@ -91,6 +105,11 @@ export default function LandingScreen() { logoPulse, taglineOpacity, taglineTranslateY, ctaOpacity, ctaTranslateY, footerOpacity, ]); + // Early-return MUSS nach allen Hooks stehen (Rules of Hooks) — sonst wirft + // React "Rendered fewer hooks than expected" wenn sich loading/session zwischen + // Renders ändert. + if (loading || session) return null; + return ( {/* Top breathing glow */} diff --git a/apps/rebreak-native/app/profile/[userId].tsx b/apps/rebreak-native/app/profile/[userId].tsx index 83d8563..f844448 100644 --- a/apps/rebreak-native/app/profile/[userId].tsx +++ b/apps/rebreak-native/app/profile/[userId].tsx @@ -57,7 +57,7 @@ function ForeignStat({ value, label }: StatProps) { + + + { - tryUnlock(); + if (AppState.currentState === 'active') { + tryUnlock(); + } }, [tryUnlock]); // Rückkehr aus dem Hintergrund zur noch gesperrten App → erneut prompten diff --git a/apps/rebreak-native/components/mail/ConnectMailSheet.tsx b/apps/rebreak-native/components/mail/ConnectMailSheet.tsx index 40e17b5..1ca8483 100644 --- a/apps/rebreak-native/components/mail/ConnectMailSheet.tsx +++ b/apps/rebreak-native/components/mail/ConnectMailSheet.tsx @@ -28,6 +28,8 @@ type ProviderConfig = { color: string; guideKey: string; guideUrl: string; + disabled?: boolean; + disabledLabelKey?: string; }; const PROVIDERS: ProviderConfig[] = [ @@ -54,6 +56,8 @@ 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', }, { id: 'yahoo', @@ -356,9 +360,10 @@ function ProviderGrid({ {providers.map((p) => ( onSelect(p)} - activeOpacity={0.7} - style={{ width: '47%' }} + onPress={p.disabled ? undefined : () => onSelect(p)} + activeOpacity={p.disabled ? 1 : 0.7} + disabled={p.disabled} + style={{ width: '47%', opacity: p.disabled ? 0.45 : 1 }} > {t(p.labelKey)} + {p.disabled && p.disabledLabelKey && ( + + {t(p.disabledLabelKey)} + + )} - + {!p.disabled && ( + + )} ))} diff --git a/apps/rebreak-native/components/profile/ApprovedDomainsList.tsx b/apps/rebreak-native/components/profile/ApprovedDomainsList.tsx index 0cfa7bf..77173ba 100644 --- a/apps/rebreak-native/components/profile/ApprovedDomainsList.tsx +++ b/apps/rebreak-native/components/profile/ApprovedDomainsList.tsx @@ -37,7 +37,7 @@ export function ApprovedDomainsList({ domains, loading }: Props) { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', - backgroundColor: colors.surface, + backgroundColor: colors.card, borderWidth: 1, borderColor: colors.border, borderRadius: 14, @@ -64,7 +64,7 @@ export function ApprovedDomainsList({ domains, loading }: Props) { = { - active: 'aktiv', - resolved: 'beendet', - cancelled: 'abgebrochen', -}; +const WEEKS = 8; +const MAX_BAR_HEIGHT = 28; +const MIN_BAR_HEIGHT = 2; -const statusColor: Record = { - active: { bg: '#fff7ed', text: '#c2410c' }, - resolved: { bg: '#f0fdf4', text: '#15803d' }, - cancelled: { bg: '#f5f5f5', text: '#737373' }, -}; +function getMondayOfWeek(date: Date): Date { + const d = new Date(date); + const day = d.getDay(); + const diff = (day === 0 ? -6 : 1 - day); + d.setDate(d.getDate() + diff); + d.setHours(0, 0, 0, 0); + return d; +} + +function buildWeekBuckets(cooldowns: CooldownEntry[]): number[] { + const now = new Date(); + const currentWeekMonday = getMondayOfWeek(now); + + const buckets: number[] = Array(WEEKS).fill(0); + + for (const c of cooldowns) { + if (!c.rawStartedAt) continue; + const started = new Date(c.rawStartedAt); + const weekMonday = getMondayOfWeek(started); + const diffMs = currentWeekMonday.getTime() - weekMonday.getTime(); + const diffWeeks = Math.round(diffMs / (7 * 24 * 60 * 60 * 1000)); + if (diffWeeks >= 0 && diffWeeks < WEEKS) { + const bucketIndex = WEEKS - 1 - diffWeeks; + buckets[bucketIndex]++; + } + } + + return buckets; +} + +function formatLastDate(cooldowns: CooldownEntry[], language: string): string { + if (cooldowns.length === 0) return ''; + const sorted = [...cooldowns].sort( + (a, b) => new Date(b.rawStartedAt).getTime() - new Date(a.rawStartedAt).getTime(), + ); + const latest = new Date(sorted[0].rawStartedAt); + if (language === 'de') { + const day = String(latest.getDate()).padStart(2, '0'); + const month = String(latest.getMonth() + 1).padStart(2, '0'); + return `${day}.${month}.`; + } + return latest.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); +} + +function formatAvg(totalCount: number, language: string): string { + if (totalCount === 0) return '0'; + const avg = WEEKS / totalCount; + if (language === 'de') { + return avg.toFixed(1).replace('.', ','); + } + return avg.toFixed(1); +} export function StreakSection({ currentDays, longestDays, startDate, cooldowns }: Props) { const colors = useColors(); + const { t, i18n } = useTranslation(); + const lang = i18n.language ?? 'de'; + + const buckets = buildWeekBuckets(cooldowns); + const maxCount = Math.max(...buckets, 1); + const totalInWindow = buckets.reduce((s, v) => s + v, 0); + const cooldownsInWindow = totalInWindow; + + const lastDate = cooldowns.length > 0 ? formatLastDate(cooldowns, lang) : null; + const avgStr = formatAvg(cooldownsInWindow, lang); + + const countLabel = + cooldownsInWindow === 0 + ? t('profile.cooldown.none') + : cooldownsInWindow === 1 + ? t('profile.cooldown.count_one', { weeks: WEEKS }) + : t('profile.cooldown.count_other', { n: cooldownsInWindow, weeks: WEEKS }); + + const avgLabel = + cooldownsInWindow > 0 && lastDate + ? t('profile.cooldown.avg_last', { avg: avgStr, date: lastDate }) + : null; + return ( - STREAK + {t('profile.streak_section_label')} - Tage geschützt + {t('profile.streak_days_protected')} - seit {startDate} + {t('profile.streak_since', { date: startDate })} - Längste Streak: {longestDays} Tage + {t('profile.streak_longest', { days: longestDays })} - {cooldowns.length > 0 ? ( - + + - COOLDOWN-VERLAUF + {t('profile.cooldown.heading')} + + {t('profile.cooldown.window_label', { weeks: WEEKS })} + + + - {cooldowns.map((c, idx) => { - const isLast = idx === cooldowns.length - 1; - const colorPair = statusColor[c.status]; + {buckets.map((count, i) => { + const isEmpty = count === 0; + const barHeight = isEmpty + ? MIN_BAR_HEIGHT + : Math.max( + MIN_BAR_HEIGHT, + Math.min(count, 5) / Math.min(maxCount, 5) * MAX_BAR_HEIGHT, + ); return ( - - - - {!isLast ? ( - - ) : null} - - - - - - - {c.startedAt} - - - {c.durationLabel} - - - - - {statusLabel[c.status].toUpperCase()} - - - - {c.reason ? ( - - {c.reason} - - ) : null} - + + ); })} + + + {buckets.map((_, i) => ( + + + {t('profile.cooldown.week_label', { n: i + 1 })} + + + ))} + + + + {countLabel} + + + {avgLabel ? ( + + {avgLabel} + + ) : null} - ) : null} + ); } diff --git a/apps/rebreak-native/components/profile/UrgeStatsCard.tsx b/apps/rebreak-native/components/profile/UrgeStatsCard.tsx index d8a9d77..11215ff 100644 --- a/apps/rebreak-native/components/profile/UrgeStatsCard.tsx +++ b/apps/rebreak-native/components/profile/UrgeStatsCard.tsx @@ -45,7 +45,7 @@ export function UrgeStatsCard({ sessions, overcome, helpedBy, topEmotion }: Prop ((set, get) => ({ ready: false, init: async () => { + // Idempotenz: nur beim allerersten init() den locked-Default setzen. Spätere + // init()-Calls (z.B. wenn RootLayoutInner durch router.replace re-mountet) + // dürfen den aktuellen locked-Zustand NICHT zurücksetzen — sonst entsteht + // eine Endlosschleife: unlock → re-mount → init() → locked=true wieder. + const alreadyReady = get().ready; if (!LocalAuthentication) { // Native-Modul fehlt (alter Dev-Client) → Sperre nicht verfügbar, App läuft weiter. set({ enabled: false, available: false, locked: false, ready: true }); @@ -79,9 +84,9 @@ export const useAppLockStore = create((set, get) => ({ set({ enabled, available, - // Cold-Start: wenn aktiviert → sofort gesperrt starten (kein Flash von App-Inhalt, - // der AppLockGate rendert dann den LockScreen bevor irgendwas sichtbar wird). - locked: enabled, + // Cold-Start: locked=enabled (kein Flash von App-Inhalt vor LockScreen). + // Re-Init: aktuellen locked-Stand erhalten — sonst Loop. + locked: alreadyReady ? get().locked : enabled, ready: true, }); }, diff --git a/apps/rebreak-native/stores/auth.ts b/apps/rebreak-native/stores/auth.ts index 935bd7c..74d5515 100644 --- a/apps/rebreak-native/stores/auth.ts +++ b/apps/rebreak-native/stores/auth.ts @@ -97,6 +97,16 @@ export const useAuthStore = create((set) => ({ if (error) return { error: error.message }; if (!data.url) return { error: 'Kein OAuth-URL erhalten' }; + // Cleanup eines evtl. noch offenen WebBrowser-Sessions aus einem vorherigen, + // abgebrochenen OAuth-Versuch — sonst wirft openAuthSessionAsync mit + // „Another web browser is already open". Idempotent, safe auch wenn nichts + // offen ist. + try { + await WebBrowser.dismissAuthSession(); + } catch { + // ignore + } + const result = await WebBrowser.openAuthSessionAsync(data.url, redirectUri); if (result.type !== 'success') { diff --git a/backend/docs/mail-outlook-oauth-plan.md b/backend/docs/mail-outlook-oauth-plan.md new file mode 100644 index 0000000..506d889 --- /dev/null +++ b/backend/docs/mail-outlook-oauth-plan.md @@ -0,0 +1,476 @@ +# Outlook OAuth2 — Implementierungsplan + +Stand: 2026-05-13 +Autor: Mo (Mail-Stack-Owner) +Status: Plan, kein Code + +--- + +## 1. Status-Recherche: Microsoft Basic-Auth-Deprecation + +### Was ist passiert + +Microsoft hat Basic-Auth (username + password) für consumer-Outlook-Mailboxen +(outlook.com, hotmail.com, hotmail.de, live.com, live.de, msn.com) schrittweise +abgeschaltet: + +- **September 2024**: Vollständige Abschaltung für neue IMAP/POP/SMTP-Verbindungen + mit Basic-Auth auf consumer-Tenants. Bestehende Verbindungen hatten eine + Übergangsfrist. +- **Stand Mai 2026**: Basic-Auth ist für alle consumer-Outlook-Postfächer tot. + IMAP-Login mit Passwort schlägt mit `[AUTHENTICATIONFAILED]` fehl — egal ob + App-Passwort oder normales Passwort. + +### Edge-Cases + +| Szenario | Basic-Auth möglich? | +|---|---| +| outlook.com / hotmail / live / msn — consumer | Nein, komplett tot | +| Microsoft 365 Business (firmeneigene Domain, Azure-AD-Tenant) | Nein, Admins können es nicht reaktivieren | +| Outlook.com custom domain (eigene Domain via Outlook-Webmail) | Nein, gleiche Infrastruktur | +| On-Premise Exchange (eigener Firmen-Server) | Hypothetisch ja, aber nicht unser Use-Case | + +**Fazit**: Es gibt keinen Edge-Case der uns rettet. Der App-Passwort-Guide im +ConnectMailSheet ist für Outlook-User seit September 2024 nutzlos. Jeder +Outlook-User der jetzt "Verbinden" drückt bekommt vom Backend +`AUTHENTICATIONFAILED` zurück. + +### Benoetigte OAuth-Scopes + +Fuer IMAP read + delete via XOAUTH2 gegen Microsoft Identity Platform: + +``` +https://outlook.office.com/IMAP.AccessAsUser.All +offline_access +openid +``` + +- `IMAP.AccessAsUser.All` — erlaubt IMAP-Zugriff im Namen des Users (lesen, + loeschen, verschieben). Kein weiterer Mail-Scope noetig. +- `offline_access` — liefert einen refresh_token (ohne ihn gibt es keinen + refresh_token, nur kurze access_tokens). Pflicht fuer langlebige IDLE-Sessions. +- `openid` — liefert sub/email im ID-Token fuer Account-Identifikation. + +Explizit NICHT anfordern: `Mail.Read`, `Mail.ReadWrite`, `Contacts.*`, +`Calendars.*`, `User.Read` (ausser sub/email). Minimale Scope-Anforderung. + +### Consumer Identity Platform vs Azure-AD + +Microsoft hat zwei Systeme: +- **Microsoft Identity Platform v2 (consumers)** — fuer outlook.com/hotmail-Privat- + konten. Endpoint: `https://login.microsoftonline.com/consumers/oauth2/v2.0/...` + oder tenant-agnostisch `common`. Azure-App-Registrierung mit "Supported account + types: Personal Microsoft accounts only" oder "Any Microsoft account (multi-tenant + + personal)". +- **Azure-AD / Entra ID (work/school)** — fuer M365-Business. Nicht unser + primaerer Use-Case. + +Fuer Rebreak: App-Registrierung mit `consumers`-Endpoint — deckt alle genannten +Domains ab (outlook.com, hotmail, live, msn). Wer ein M365-Business-Konto hat, +faellt spaeter unter den gleichen Flow wenn wir auf `common` wechseln. + +--- + +## 2. Architektur-Plan + +### 2.1 Azure-App-Registrierung + +Einmaliges Setup im Azure-Portal (portal.azure.com): + +| Feld | Wert | +|---|---| +| Name | Rebreak Mail Access | +| Supported account types | Personal Microsoft accounts only | +| Redirect URI (Mobile) | `msauth.org.rebreak.app://auth` (MSAL-Schema) | +| Redirect URI (Web/BFF) | `https://api.rebreak.org/api/mail/oauth/microsoft/callback` | +| API Permissions | `IMAP.AccessAsUser.All` (delegated), `offline_access`, `openid` | +| Client secret | Ja (fuer BFF-Token-Exchange) | +| Public client flows | Ja aktivieren (fuer PKCE) | + +Scopes muessen im Portal unter "API Permissions" explizit hinzugefuegt und fuer +`consumers`-Tenant fuer alle User freigegeben werden. Kein "Grant admin consent" +noetig fuer delegated permissions auf consumer-Tenant. + +**Multi-Tenant-Approval erforderlich?** Nein. Bei "Personal Microsoft accounts +only" gibt es keinen App-Review-Prozess bei Microsoft — jeder MS-User kann der +App konsentieren. App-Reviews sind nur noetig wenn man `All organizations`-Tenant +anfordert und enterprise-Features braucht. + +### 2.2 OAuth-Flow: BFF-Pattern (Backend-mediated) + +Empfehlung: **BFF-Pattern**, nicht PKCE direkt im Mobile-Client. + +Begruendung: +- Client-secret darf nicht im App-Bundle liegen (App-Store-Guidelines, Reverse- + Engineering). PKCE ohne client_secret ist moeglich aber dann kein refresh_token + via MSAL fuer native — Microsoft erlaubt es fuer public clients, aber Token- + Rotation ist dann Clients-Sache. +- Wir haben bereits den BFF-Ansatz beim Auth-Login. Konsistenz. +- Token-Storage (encrypted, server-side) ist ohnehin Backend-Aufgabe. + +**Flow-Sequenz:** + +``` +Native App Backend Microsoft + | | | + | GET /api/mail/oauth/ | | + | microsoft/authorize | | + | (mit state+code_challenge) | | + |---------------------------->| | + | | build auth URL | + | 302 redirect URL | (PKCE, state, scopes) | + |<----------------------------| | + | | | + | WebBrowser.openAuthSession | | + | oeffnet MS-Login | | + |-------------------------------------------->| | + | | | User loggt | + | | | ein, | + | | | konsentiert | + |<--------------------------------------------| | + | redirect: .../callback?code=XXX&state=YYY | | + | | | + | POST /api/mail/oauth/ | | + | microsoft/exchange | | + | body: { code, state } | | + |---------------------------->| | + | | POST token endpoint | + | | (code + code_verifier) | + | |---------------------------->| + | |<----------------------------| + | | { access_token, | + | | refresh_token, | + | | expires_in } | + | | | + | | decrypt+store tokens | + | | upsert MailConnection | + | | | + | { connected: true } | | + |<----------------------------| | +``` + +Zwei neue Backend-Endpoints: +- `GET /api/mail/oauth/microsoft/authorize` — generiert state + PKCE-Verifier, + speichert state temporaer in DB/Session, gibt redirect URL zurueck +- `POST /api/mail/oauth/microsoft/exchange` — empfaengt code + state, tauscht + gegen tokens, speichert in MailConnection + +### 2.3 Token-Storage: Schema-Aenderung (Eskalation an rebreak-backend) + +**ESKALATION AN rebreak-backend erforderlich.** + +Das aktuelle `MailConnection`-Schema hat `passwordEncrypted: String`. Fuer OAuth +brauchen wir: + +```prisma +// Neue Felder in MailConnection: +authMethod String @default("password") @map("auth_method") +// "password" | "oauth2_microsoft" | "oauth2_google" (future) + +oauthAccessToken String? @map("oauth_access_token") // AES-256-GCM encrypted +oauthRefreshToken String? @map("oauth_refresh_token") // AES-256-GCM encrypted +oauthTokenExpiry DateTime? @map("oauth_token_expiry") // UTC, naechste Ablaufzeit +oauthScope String? @map("oauth_scope") // gespeicherter Scope-String +``` + +`passwordEncrypted` bleibt fuer bestehende password-basierte Connections. + +**Fuer OAuth-Connections**: `passwordEncrypted` = leer string oder `"oauth"` als +Marker, damit bestehender Code nicht bricht. Besser: `authMethod`-Flag pruefe +zuerst. + +Schema-Migration: `ALTER TABLE rebreak.mail_connections ADD COLUMN ...` (4 neue +Spalten). Kein Breaking Change fuer bestehende Rows. + +### 2.4 IMAP-Connect-Logik: XOAUTH2 in ImapFlow + +**Gute Nachricht**: `imapflow` (aktuell `^1.2.18`) unterstuetzt XOAUTH2 nativ. + +Aktueller Auth-Block in `connect.post.ts` und `imap-idle/index.mjs`: +```js +auth: { user: email, pass: password } +``` + +Fuer OAuth: ImapFlow akzeptiert stattdessen: +```js +auth: { + user: email, + accessToken: decryptedAccessToken +} +``` + +ImapFlow baut daraus automatisch den XOAUTH2-SASL-String. Kein manueller +Base64-Encoding noetig, keine Library-Aenderung erforderlich. + +`imap-providers.ts` braucht ein neues Interface: +```ts +export interface ImapAuth { + type: 'password' | 'oauth2'; + value: string; // password (plaintext, decrypted) ODER access_token +} +``` + +Die Resolve-Logik in `connect.post.ts` muss `authMethod` aus MailConnection +lesen und die richtige `ImapAuth` zusammenbauen. + +### 2.5 Token-Refresh-Flow + +**Das haerteste Problem.** Access-tokens laufen bei Microsoft nach 1 Stunde ab. + +Betroffen sind zwei Stellen: + +**A. IMAP-Idle-Daemon** (langlebige Verbindung, laeuft tage-/wochenlang): + +Der Daemon muss vor jedem connect (und nach AUTHENTICATIONFAILED-Fehlern) pruefen +ob der access_token noch gueltig ist. Refresh-Logik: + +``` +1. oauthTokenExpiry aus DB lesen +2. Wenn expiry < now + 5min: + a. POST https://login.microsoftonline.com/consumers/oauth2/v2.0/token + mit: grant_type=refresh_token, refresh_token=, client_id, client_secret + b. Neues access_token + refresh_token in DB speichern (encrypted) + c. oauthTokenExpiry updaten +3. ImapFlow mit frischem access_token verbinden +``` + +Refresh im Daemon direkt (kein HTTP-Roundtrip zum Backend noetig — Daemon hat +direkten DB-Zugriff). Der Daemon erhaelt client_id + client_secret als Env-Vars. + +**Token-Rotation**: Microsoft kann bei refresh auch ein neues refresh_token liefern +("refresh token rotation"). Daemon muss das neue refresh_token persistieren, +sonst ist nach einem Refresh der naechste fehlgeschlagen. + +**B. scan.post.ts / connect.post.ts** (kurze Connections): + +Beim On-Demand-Scan: pruefe `oauthTokenExpiry` und refresh wenn noetig, bevor +IMAP-Connection aufgebaut wird. Da dieser Code im Nitro-Kontext laeuft, kann +er direkt Prisma nutzen. + +**Refresh-Token-Revocation bei User-Logout / Account-Loeschung**: Backend muss +`POST https://login.microsoftonline.com/consumers/oauth2/v2.0/logout` aufrufen +wenn User die Verbindung trennt oder Account loescht. Sonst bleibt unsere App- +Autorisierung bei Microsoft aktiv. + +--- + +## 3. ConnectMailSheet UX-Plan (fuer rebreak-native-ui-Agent) + +### Geaenderter Flow fuer Outlook + +Aktuell: Outlook-Provider-Tile -> Formular mit Email + App-Passwort-Hinweis + Link. + +Neu: Outlook-Provider-Tile -> Anderer View (kein Passwort-Formular): + +``` +[Tile: Outlook / Hotmail / Live] + | + v + View: "outlook-oauth" + +---------------------------------+ + | [Outlook-Icon] | + | Mit Microsoft anmelden | + | | + | Rebreak benoetigt Zugriff auf | + | dein Postfach um Gluecksspiel- | + | Mails automatisch zu loeschen. | + | | + | [Schild-Icon] Datenschutz: | + | Wir lesen keine Inhalte. Nur | + | Absender + Betreff zum Matching| + | | + | [Button] Mit Microsoft anmelden| + | | + | [Spinner waehrend OAuth laeuft]| + +---------------------------------+ +``` + +States: +- **idle**: Button aktiv, Datenschutz-Hinweis sichtbar +- **loading**: Button disabled, ActivityIndicator, Text "Verbindung wird hergestellt..." +- **error**: Roter Error-Text unter Button (z.B. "Zugriff verweigert" wenn User + Consent ablehnt, oder "Verbindung fehlgeschlagen" bei Network-Error) +- **success**: Sheet schliesst sich, onSuccess() wird aufgerufen + +Technisch im Client: +``` +1. Button-Tap → GET /api/mail/oauth/microsoft/authorize +2. Backend gibt { authUrl: "https://login.microsoftonline.com/..." } zurueck +3. expo-web-browser: WebBrowser.openAuthSessionAsync(authUrl, redirectUri) +4. Deep-Link-Handler empfaengt Callback-URL mit code + state +5. POST /api/mail/oauth/microsoft/exchange mit { code, state } +6. On success: handleClose() + onSuccess() +``` + +Redirect-URI in der App: `msauth.org.rebreak.app://auth` — muss in +`app.json`-Scheme registriert und in Azure-App-Registrierung eingetragen sein. + +**Bestehende Provider unveraendert**: Gmail, iCloud, Yahoo, GMX, Other behalten +den Passwort-Formular-Flow. Nur Outlook-Tile bekommt anderen View. + +--- + +## 4. DSGVO-/Compliance-Aspekte (fuer Hans-Mueller-DSB-Review) + +**ESKALATION AN hans-mueller** fuer formelles Review. + +### 4.1 Microsoft als Sub-Auftragsverarbeiter + +Microsoft wird durch den OAuth-Flow zusaetzlicher Sub-AV (Art. 28 DSGVO). +Microsoft hat ein Standard-DPA das automatisch gilt wenn man Azure-Services nutzt +(Microsoft Products and Services Data Protection Addendum — DPA). Zu pruefen: +- Gilt das DPA auch fuer consumer Microsoft Identity Platform? +- Muss in unserem AV-Vertraege-Verzeichnis (VVT) erwaehnt werden? +- Microsoft hat EU-Datenzentren — Transfer-Grundlage sollte Standard-Vertragsklauseln + oder Adequacy-Decision sein. + +### 4.2 Token-Speicherung = sensibler als Passwort + +Ein refresh_token gibt persistenten Zugriff auf das Postfach bis zur Revocation — +laenger als ein App-Passwort (das der User jederzeit in Sekunden zurueckziehen +kann). Konsequenzen: +- Verschluesselung at-rest: gleicher AES-256-GCM wie bei `passwordEncrypted`. + Gleicher ENCRYPTION_KEY. Kein anderer Speicherweg. +- Zugriff auf refresh_token = Zugriff auf gesamtes Postfach. Breach-Impact hoeher + als bei App-Passwort. +- Im Datenschutzhinweis in der App und in der Datenschutzerklaerung explizit + erwaehnen: "Wir speichern einen Zugriffstoken der im Namen des Users auf das + Postfach zugreift". + +### 4.3 Datenminimierung + +Scopes beschraenken auf: +- `IMAP.AccessAsUser.All` — Minimum fuer IMAP +- `offline_access` — Minimum fuer Token-Refresh +- `openid` — fuer Email-Identifikation (kein `profile`-Scope) + +Kein `User.Read.All`, kein `Contacts.*`, kein `Calendars.*`. + +### 4.4 Loeschpflicht / Widerrufs-Pflicht + +Bei User-Disconnect oder Account-Loeschung: +1. refresh_token + access_token aus DB loeschen +2. Token bei Microsoft revoken via: + `POST https://login.microsoftonline.com/consumers/oauth2/v2.0/token/revoke` + (mit refresh_token als Parameter) + +Ohne Revocation bleibt Rebreaks App-Autorisierung bei Microsoft aktiv — auch wenn +wir die DB-Eintraege loeschen. + +### 4.5 Speicherort + +Token in `MailConnection`-Tabelle, gleicher Postgres-Host wie alle anderen User- +daten. Kein separater Secret-Store noetig wenn AES-256-GCM konsistent angewandt +wird. + +--- + +## 5. Aufwands-Schaetzung + +### MVP-Scope + +MVP = OAuth-Login funktioniert, User kann Outlook verbinden, IMAP-IDLE loescht +Gambling-Mails, Token-Refresh laeuft automatisch. + +| Komponente | Aufwand | +|---|---| +| Azure-App-Registrierung (einmaliges Setup) | 0.5 Tage | +| Schema-Migration (4 neue Spalten, rebreak-backend) | 0.5 Tage | +| Backend: 2 neue Endpoints (authorize + exchange) | 1.5 Tage | +| connect.post.ts: authMethod-Logik + XOAUTH2-Support | 0.5 Tage | +| imap-idle: Token-Refresh-Logik + XOAUTH2-Auth | 1.5 Tage | +| scan.post.ts: Token-Refresh vor on-demand-scan | 0.5 Tage | +| disconnect.delete.ts: Token-Revocation bei MS | 0.5 Tage | +| ConnectMailSheet: Outlook-OAuth-View (native-ui-agent) | 1.0 Tag | +| Deep-Link-Handling in App + expo-web-browser Setup | 0.5 Tage | +| Testen end-to-end (inkl. Token-Refresh-Simulation) | 1.0 Tag | +| **Gesamt** | **~8 Personentage** | + +### Risiken + +**1. Microsoft Rate Limits auf Free-Tier Azure-App** + +Azure-Apps haben per default Rate-Limits auf den Token-Endpoint. Bei vielen +Usern gleichzeitig (Token-Refresh alle ~55min pro User) koennte das ein Problem +werden. Grenzwert: 30 Requests/Sekunde per App fuer `/token`-Endpoint. +Bei 1000 aktiven Outlook-Usern: ~18 Refreshes/Minute → kein Problem. Bei 10.000 +Users: Grenzwert naeherungsweise erreicht. Fruehzeitig Azure-Subscription-Limit +pruefen. + +**2. Token-Rotation race condition im IDLE-Daemon** + +Wenn mehrere IDLE-Sessions parallel starten (z.B. nach Daemon-Restart) und alle +gleichzeitig ein abgelaufenes Token refreshen wollen, koennen race conditions +entstehen: doppelter Refresh → alter refresh_token ungueltig → zweite Session +failt. Loesung: DB-Lock oder last-writer-wins mit Timestamp-Check. + +**3. Consumer-Tenant Consent-Screen** + +Beim ersten OAuth-Login sieht der User den Microsoft-Consent-Screen mit der +Formulierung "Rebreak moechte auf dein Postfach zugreifen". Fuer manche User +(besonders aengstliche) koennte das abschreckend wirken. Das ist kein technisches +Risiko aber ein UX-Risiko — der Datenschutz-Hinweis im Sheet muss das vorab +erklaeren. + +**4. App-Registrierung: Publisher-Verification** + +Microsoft kann nicht-verifizierte Publisher-Apps auf dem Consent-Screen als +"unverified" markieren. Fuer Produktivbetrieb sollte Publisher-Verification in +Azure abgeschlossen werden (Domain-Verifikation von rebreak.org). Aufwand: ~1 Tag +einmalig. Ohne Verifikation funktioniert der Flow trotzdem, aber der Consent- +Screen zeigt "unverified publisher" — schlechtes Vertrauen. + +**5. Apple App-Store-Review: OAuth-Flows** + +OAuth-Flows in iOS-Apps koennen zu App-Store-Review-Verzoegerungen fuehren wenn +der Reviewer nicht einen echten Microsoft-Account zum Testen hat. Testaccount +fuer Review bereitstellen (outlook.com-Testaccount mit Gambling-Mails). + +**6. Kein App-Review bei Microsoft selbst erforderlich** + +"Personal Microsoft accounts only"-Apps brauchen keine Microsoft-seitige +Freigabe. Kein Warten auf MS-Approval. + +--- + +## 6. Abhaengigkeiten und naechste Schritte + +### Sofortige Eskalationen + +1. **rebreak-backend**: Schema-Migration fuer 4 neue Felder in `MailConnection`. + Neue Felder: `auth_method`, `oauth_access_token`, `oauth_refresh_token`, + `oauth_token_expiry`. Migration kann non-destructive (additive) sein. + +2. **hans-mueller**: DSGVO-Review der Token-Speicherung (Abschnitt 4). Insbesondere: + Microsoft als Sub-AV ins VVT aufnehmen, Datenschutzerklaerung anpassen + (refresh_token = persistenter Zugriff), Revocations-Pflicht bei Loeschung. + +### Entscheidung vor Implementierungsstart + +- Azure-Account + App-Registrierung: wer legt an? (ops-Aufgabe) +- client_id + client_secret: werden via Infisical verwaltet (klar), aber + Infisical-Secret-Naming vorab festlegen. +- Redirect-URI-Schema (`msauth.org.rebreak.app`): muss in `app.json` registriert + sein bevor iOS-Build fuer Tests. + +### Kein Handlungsbedarf bis Schema-Migration done + +Die Backend-Endpoints koennen erst nach dem Schema-Change implementiert werden. +Warten auf rebreak-backend, dann direkt loslegen. + +--- + +## 7. Was wir heute sofort tun koennen (ohne Schema-Change) + +Unabhaengig vom OAuth-Implementierungs-Timeline: + +1. **ConnectMailSheet**: Outlook-Tile sofort deaktivieren oder Hinweis einblenden + "Outlook wird bald unterstuetzt". Besser als den User einen Fehler erleben + lassen ("AUTHENTICATIONFAILED" nach Eingabe eines App-Passworts das sowieso + nicht funktioniert). Das ist eine UI-Aenderung fuer native-ui-agent. + +2. **imap-providers.ts**: `isOAuthRequired`-Flag fuer Outlook-Domains vorbereiten, + damit connect.post.ts frueizeitig auf "oauth not yet implemented" antworten + kann statt mit generischem Auth-Fehler zu failen. + +Diese zwei Punkte koennen vor der Schema-Migration deployed werden.