From 96597ffaff370176433a254e9e649e29c9c68162 Mon Sep 17 00:00:00 2001 From: chahinebrini Date: Thu, 28 May 2026 15:13:21 +0200 Subject: [PATCH] =?UTF-8?q?feat(mail):=20Gmail=20OAuth2=20(XOAUTH2/PKCE)?= =?UTF-8?q?=20=E2=80=94=20replaces=20App-Password=20for=20Gmail?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reason: App-Passwords sind für manche Gmail-Accounts faktisch unreliable (silent server-side revoke trotz aktiver 2FA). Empirisch verifiziert 2026-05-28 — iOS Mail (Apple's eigener Client) fail't mit identischen App-Passwords. OAuth ist Google's stable Pfad. Pattern 1:1 von bestehender Microsoft-OAuth-Integration übernommen. Co-Authored-By: Claude Opus 4.7 --- backend/imap-idle/index.mjs | 169 ++++++++++++--- backend/nitro.config.ts | 9 + .../api/mail-connections/[id].delete.ts | 33 +++ backend/server/api/mail/connect.post.ts | 10 + .../api/mail/oauth/google/callback.post.ts | 184 ++++++++++++++++ .../server/api/mail/oauth/google/init.post.ts | 88 ++++++++ backend/server/api/mail/scan-internal.post.ts | 7 +- backend/server/api/mail/scan.post.ts | 7 +- backend/server/db/mail.ts | 106 ++++++++-- backend/server/utils/google-oauth.ts | 198 ++++++++++++++++++ backend/server/utils/mail-auth.ts | 32 ++- 11 files changed, 783 insertions(+), 60 deletions(-) create mode 100644 backend/server/api/mail/oauth/google/callback.post.ts create mode 100644 backend/server/api/mail/oauth/google/init.post.ts create mode 100644 backend/server/utils/google-oauth.ts diff --git a/backend/imap-idle/index.mjs b/backend/imap-idle/index.mjs index eb1dbfb..a4d1461 100644 --- a/backend/imap-idle/index.mjs +++ b/backend/imap-idle/index.mjs @@ -6,16 +6,18 @@ * POST /api/mail/scan-internal gegen das lokale Backend — ohne 30min-Warte. * * Auth-Methoden: - * app_password — Gmail, iCloud, GMX, etc. (App-Password / IMAP-Passwort) + * app_password — Gmail (App-Password), iCloud, GMX, etc. * oauth2_microsoft — Outlook / Hotmail / O365 via XOAUTH2 (ImapFlow-nativ) + * oauth2_google — Gmail via Google OAuth2 XOAUTH2 (kein App-Password nötig) * * 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, 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 + * DATABASE_URL — Postgres-Connection-String + * ADMIN_SECRET — Header-Secret für /api/mail/scan-internal + * 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 + * GOOGLE_OAUTH_CLIENT_ID — Google Cloud OAuth Client ID (iOS Native App) + * NODE_ENV — production / staging * * Starten: * node index.mjs @@ -42,6 +44,9 @@ const ADMIN_SECRET = const MS_OAUTH_CLIENT_ID = process.env.MS_OAUTH_CLIENT_ID || ""; +const GOOGLE_OAUTH_CLIENT_ID = + process.env.GOOGLE_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 @@ -280,6 +285,94 @@ async function refreshAndSaveTokensDaemon(connectionId, clientId) { return data.access_token; } +/** + * Refresht Google Access-Token direkt via HTTP. + * Race-Condition-Strategie analog zu refreshAndSaveTokensDaemon (MS). + * + * Google-Spezifik: + * - Google rotiert refresh_tokens NICHT bei normalem Einsatz. Das ausgestellte + * refresh_token ist langlebig. Exceptions: User-Revocation, 6-Monate-Inaktivität. + * - Response enthält KEIN neues refresh_token → immer das bestehende behalten. + * - Kein scope-Parameter nötig beim Refresh (Google ignoriert ihn, MS braucht ihn). + * - auth_method-Filter: WHERE auth_method = 'oauth2_google' für die DB-Abfrage. + */ +async function refreshGoogleTokensDaemon(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_google'`, + [connectionId], + ); + + const conn = rows[0]; + if (!conn?.oauth_refresh_token) { + throw new Error( + `Connection ${connectionId} has no oauth_refresh_token (google) — cannot refresh`, + ); + } + + const currentExpiry = conn.oauth_token_expiry; + const decryptedRefreshToken = decrypt(conn.oauth_refresh_token); + + // Step 2: Google Token-Endpoint + const body = new URLSearchParams({ + grant_type: "refresh_token", + client_id: clientId, + refresh_token: decryptedRefreshToken, + // kein scope-Parameter — Google ignoriert ihn beim Refresh + }); + + const res = await fetch("https://oauth2.googleapis.com/token", { + 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(`Google token refresh failed (${res.status}): ${errText}`); + } + + const data = await res.json(); + if (!data.access_token) { + throw new Error("Google refresh response missing access_token"); + } + + const newExpiry = new Date(Date.now() + data.expires_in * 1000); + // Google rotiert NICHT — refresh_token bleibt unverändert + const encryptedAccess = encrypt(data.access_token); + // refresh_token bleibt derselbe → re-encrypt aus dem bestehenden decrypted Wert + const encryptedRefresh = encrypt(decryptedRefreshToken); + + // Step 3: Optimistic update + 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) { + // Concurrent refresh — lese 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 Google 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'). @@ -301,20 +394,19 @@ async function markConnectionAuthBroken(connectionId) { * auth_method-aware: * * app_password → { type: 'password', user, pass } - * oauth2_microsoft → { type: 'xoauth2', user, accessToken } + * oauth2_microsoft → { type: 'xoauth2', user, accessToken } (MS XOAUTH2) + * oauth2_google → { type: 'xoauth2', user, accessToken } (Google XOAUTH2) * - * Für OAuth: wenn Token in <5min abläuft, wird vor Connect refresht. - * Das ist der "proaktive" Refresh-Path (Hot-Path). + * Für OAuth: wenn Token in <5min abläuft, wird vor Connect proaktiv refresht. * - * Der "reaktive" Refresh-Path (Cold-Path) liegt in runSession(): - * AUTHENTICATIONFAILED während laufender Session → refreshAndSaveTokensDaemon() + * Der reaktive Refresh-Path (Cold-Path) liegt in runSession(): + * AUTHENTICATIONFAILED während laufender Session → provider-spezifischer Refresh * → 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. + * FORCE_REFRESH_FOR_TEST: wenn env IDLE_FORCE_TOKEN_REFRESH=1, immer refreshen. */ async function getCredentialsForConnection(conn) { - if (conn.authMethod === "oauth2_microsoft") { + if (conn.authMethod === "oauth2_microsoft" || conn.authMethod === "oauth2_google") { const forceRefresh = process.env.IDLE_FORCE_TOKEN_REFRESH === "1"; const fiveMinFromNow = Date.now() + TOKEN_EXPIRY_THRESHOLD_MS; const isExpiring = @@ -323,21 +415,26 @@ async function getCredentialsForConnection(conn) { 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); + console.log(`[idle/${conn.email}] proactive token refresh (${reason}, method=${conn.authMethod})`); + + let accessToken; + if (conn.authMethod === "oauth2_google") { + accessToken = await refreshGoogleTokensDaemon(conn.id, GOOGLE_OAUTH_CLIENT_ID); + } else { + 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`); + throw new Error(`Connection ${conn.id} has no oauth_access_token (method=${conn.authMethod})`); } const accessToken = decrypt(conn.oauthAccessToken); return { type: "xoauth2", user: conn.email, accessToken }; } - // Bestand: Gmail / iCloud / GMX / Custom-IMAP → App-Password + // App-Password-Pfad: iCloud, GMX, Yahoo, Custom-IMAP, Gmail-App-Password if (!conn.passwordEncrypted) { throw new Error(`Connection ${conn.id} has no password_encrypted`); } @@ -574,11 +671,15 @@ async function runSession(conn) { // ── 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)) { + const isOauthConn = + conn.authMethod === "oauth2_microsoft" || + conn.authMethod === "oauth2_google"; + + if (isOauthConn && isAuthError(err)) { authRetries++; log( conn.email, - `AUTHENTICATIONFAILED detected — refresh attempt ${authRetries}/${MAX_AUTH_RETRIES}`, + `AUTHENTICATIONFAILED detected — refresh attempt ${authRetries}/${MAX_AUTH_RETRIES} (method=${conn.authMethod})`, ); if (authRetries > MAX_AUTH_RETRIES) { @@ -591,25 +692,24 @@ async function runSession(conn) { } // 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. + // Provider-spezifisch: Google via refreshGoogleTokensDaemon, MS via refreshAndSaveTokensDaemon. 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. + let freshToken; + if (conn.authMethod === "oauth2_google") { + freshToken = await refreshGoogleTokensDaemon(conn.id, GOOGLE_OAUTH_CLIENT_ID); + } else { + freshToken = await refreshAndSaveTokensDaemon(conn.id, MS_OAUTH_CLIENT_ID); + } + // conn inline patchen 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"); + log(conn.email, `token refreshed (${conn.authMethod}) — 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.ä.) + // Refresh selbst gescheitert → normaler Backoff // authRetries bleibt erhöht — beim nächsten Auth-Fehler zählt es weiter. await updateConnectionError(conn.id, refreshErr?.message || String(refreshErr)).catch(() => {}); } @@ -736,6 +836,7 @@ function assertEnv() { 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 (!GOOGLE_OAUTH_CLIENT_ID) missing.push("GOOGLE_OAUTH_CLIENT_ID"); if (missing.length > 0) { console.error( `[idle] FEHLER: fehlende Env-Vars: ${missing.join(", ")}. Daemon startet nicht.`, diff --git a/backend/nitro.config.ts b/backend/nitro.config.ts index c873a84..d8ab2d3 100644 --- a/backend/nitro.config.ts +++ b/backend/nitro.config.ts @@ -90,6 +90,15 @@ export default defineNitroConfig({ // Infisical secret name: MS_OAUTH_CLIENT_ID msOauthClientId: process.env.MS_OAUTH_CLIENT_ID ?? "427575e1-0ec5-4468-b4a2-7ae3ce99a154", + // ─── Google OAuth (PKCE, Public Client / iOS Native) ──────────────────── + // Client-ID der Google Cloud Console App-Registrierung "Rebreak". + // Typ: iOS-App (Native Client — kein client_secret, PKCE S256). + // Scope: https://mail.google.com/ (IMAP-Vollzugriff für XOAUTH2). + // KRITISCH: prompt=consent + access_type=offline in init.post.ts sind PFLICHT + // damit Google ein refresh_token ausstellt. Ohne das: nur 1h access_token. + // Infisical secret name: GOOGLE_OAUTH_CLIENT_ID + googleOauthClientId: process.env.GOOGLE_OAUTH_CLIENT_ID ?? "864178840836-rerbjn5pl31ocd83b2qqpbc8gs2o8ego.apps.googleusercontent.com", + // ─── Bot-User-IDs (DB-User-References für Lyra/Rebreak-Bot-Posts) ──── lyraBotUserId: process.env.LYRA_BOT_USER_ID ?? "", rebreakBotUserId: process.env.REBREAK_BOT_USER_ID ?? "", diff --git a/backend/server/api/mail-connections/[id].delete.ts b/backend/server/api/mail-connections/[id].delete.ts index fa52e9b..dee091e 100644 --- a/backend/server/api/mail-connections/[id].delete.ts +++ b/backend/server/api/mail-connections/[id].delete.ts @@ -136,6 +136,39 @@ export default defineEventHandler(async (event) => { // an audit_log table write (separate from consent_logs — operational log). } + if (connection.authMethod === "oauth2_google") { + // Google unterstützt RFC 7009 Token-Revocation für Public Clients — + // anders als MS können wir das refresh_token serverseitig invalidieren. + // POST https://oauth2.googleapis.com/revoke?token= + // Kein client_secret nötig (public client). + const refreshToken = await getDecryptedRefreshToken(connection.id, user.id); + + let revokeAttemptResult: "no_token" | "revoked" | "revoke_failed" = "no_token"; + + if (refreshToken) { + try { + const revokeRes = await fetch( + `https://oauth2.googleapis.com/revoke?token=${encodeURIComponent(refreshToken)}`, + { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" } }, + ); + if (revokeRes.ok) { + revokeAttemptResult = "revoked"; + } else { + // Häufige Fehler: token already revoked (200 trotzdem), invalid_token (400) + const body = await revokeRes.text().catch(() => ""); + console.warn(`[oauth-revoke] Google revoke HTTP ${revokeRes.status}: ${body}`); + revokeAttemptResult = "revoke_failed"; + } + } catch (err: any) { + console.warn(`[oauth-revoke] Google revoke network error: ${err?.message}`); + revokeAttemptResult = "revoke_failed"; + } + } + + // Audit log — DB-Löschung erfolgt immer unabhängig vom Revoke-Result + console.log(`[oauth-revoke-audit] connectionId=${connection.id} authMethod=oauth2_google revokeResult=${revokeAttemptResult} timestamp=${now.toISOString()}`); + } + // ── DB-Row löschen ──────────────────────────────────────────────────────── await deleteMailConnection(user.id, connectionId); diff --git a/backend/server/api/mail/connect.post.ts b/backend/server/api/mail/connect.post.ts index 0b60c42..11a2cdc 100644 --- a/backend/server/api/mail/connect.post.ts +++ b/backend/server/api/mail/connect.post.ts @@ -76,6 +76,16 @@ export default defineEventHandler(async (event) => { await client.connect(); await client.logout(); } catch (err: any) { + console.error("[mail/connect] IMAP-Fehler", { + email, + host: resolvedHost, + port: resolvedPort, + provider: provider.name, + authCode: err.authenticationFailed ? "AUTHENTICATIONFAILED" : err.code, + message: err.message, + response: err.response, + responseStatus: err.responseStatus, + }); throw createError({ statusCode: 401, message: `Verbindung fehlgeschlagen: ${err.message ?? "Ungültige Zugangsdaten"}`, diff --git a/backend/server/api/mail/oauth/google/callback.post.ts b/backend/server/api/mail/oauth/google/callback.post.ts new file mode 100644 index 0000000..016dafd --- /dev/null +++ b/backend/server/api/mail/oauth/google/callback.post.ts @@ -0,0 +1,184 @@ +import { + consumeOauthPendingState, + countMailConnections, + upsertOauthGoogleConnection, +} from "../../../../db/mail"; +import { encrypt } from "../../../../utils/crypto"; +import { + exchangeCodeForTokens, + extractEmailFromIdToken, + GOOGLE_OAUTH_SCOPES, +} from "../../../../utils/google-oauth"; +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"; + +/** + * POST /api/mail/oauth/google/callback + * + * Step 2 des Google OAuth PKCE-Flows. + * + * Wird von der nativen App aufgerufen nachdem sie den Deep-Link + * rebreak://auth/mail-oauth-callback?code=…&state=… abgefangen hat. + * + * NO requireUser — state ist der Auth-Mechanismus (CSRF-safe weil nur der User + * der den Flow gestartet hat die stateId kennt). userId kommt aus oauth_pending_states. + * + * Body: + * code: string — Authorization Code von Google + * state: string — stateId aus dem init-Step (CSRF-Validierung) + * + * Response: + * 200: { connectionId, email, provider: 'gmail_oauth' } + * 400: { error: 'invalid_body' } + * 401: { error: 'invalid_state' } + * 403: { error: 'plan_limit' } + * 502: { error: 'token_exchange_failed' } + * 502: { error: 'email_extraction_failed' } + */ +export default defineEventHandler(async (event) => { + const config = useRuntimeConfig(event); + const clientId = config.googleOauthClientId as string; + + if (!clientId) { + throw createError({ + statusCode: 500, + data: { error: "GOOGLE_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 ?? "google_error", + }, + }); + } + + const { access_token, refresh_token, expires_in, scope, id_token } = tokenResponse; + + // ── Extract email from ID-token ───────────────────────────────────────────── + let gmailEmail: string | null = hintEmail ?? null; + if (id_token) { + const extracted = extractEmailFromIdToken(id_token); + if (extracted) gmailEmail = extracted; + } + + if (!gmailEmail) { + throw createError({ + statusCode: 502, + data: { + error: "email_extraction_failed", + detail: "Could not extract email from Google ID-token. Ensure openid+email 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 upsertOauthGoogleConnection({ + userId, + email: gmailEmail, + encryptedAccessToken, + encryptedRefreshToken, + tokenExpiry, + scope: scope ?? GOOGLE_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: gmailEmail, + provider: "gmail_oauth", + title: null, + }; +}); diff --git a/backend/server/api/mail/oauth/google/init.post.ts b/backend/server/api/mail/oauth/google/init.post.ts new file mode 100644 index 0000000..0b2c808 --- /dev/null +++ b/backend/server/api/mail/oauth/google/init.post.ts @@ -0,0 +1,88 @@ +import { createOauthPendingState } from "../../../../db/mail"; +import { + GOOGLE_AUTH_BASE, + GOOGLE_REDIRECT_URI, + GOOGLE_OAUTH_SCOPES, + generateCodeVerifier, + computeCodeChallenge, + generateStateId, +} from "../../../../utils/google-oauth"; + +/** + * POST /api/mail/oauth/google/init + * + * Step 1 des Google OAuth PKCE-Flows. + * Generiert PKCE code_verifier + code_challenge, persistiert State in + * oauth_pending_states (TTL 10 min), gibt Authorization-URL zurück. + * + * Die native App öffnet die URL via expo-web-browser (WebBrowser.openAuthSessionAsync). + * Nach Login + Consent leitet Google weiter auf rebreak://auth/mail-oauth-callback?code=…&state=… + * Der Deep-Link-Handler der App ruft POST /api/mail/oauth/google/callback mit { code, state }. + * + * Google-Spezifik: + * prompt=consent — PFLICHT damit Google refresh_token zurückgibt. Ohne das: + * kein refresh_token beim Exchange → Connection lebt nur 1h. + * access_type=offline — PFLICHT für refresh_token (Google-spezifisch, MS braucht das nicht). + * + * Body (optional): + * email?: string — pre-fills login_hint + * + * Response: + * 200: { authorizationUrl: string } + * 401: not authenticated + * 500: GOOGLE_OAUTH_CLIENT_ID not configured + */ +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + const config = useRuntimeConfig(event); + const clientId = config.googleOauthClientId as string; + + if (!clientId) { + throw createError({ + statusCode: 500, + data: { error: "GOOGLE_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) ────────────────── + await createOauthPendingState({ + stateId, + userId: user.id, + codeVerifier, + email: hintEmail, + }); + + // ── Build authorization URL ───────────────────────────────────────────────── + const params = new URLSearchParams({ + client_id: clientId, + response_type: "code", + redirect_uri: GOOGLE_REDIRECT_URI, + scope: GOOGLE_OAUTH_SCOPES, + state: stateId, + code_challenge: codeChallenge, + code_challenge_method: "S256", + // access_type=offline: PFLICHT damit Google ein refresh_token ausstellt. + // Bei access_type=online (default) gibt es keinen refresh_token. + access_type: "offline", + // prompt=consent: PFLICHT damit Google den refresh_token zurückgibt + // (auch wenn der User die App schon einmal autorisiert hat). + // Ohne das: bei bestehender Berechtigung kommt kein refresh_token. + prompt: "consent", + }); + + if (hintEmail) { + params.set("login_hint", hintEmail); + } + + const authorizationUrl = `${GOOGLE_AUTH_BASE}?${params.toString()}`; + + return { authorizationUrl }; +}); diff --git a/backend/server/api/mail/scan-internal.post.ts b/backend/server/api/mail/scan-internal.post.ts index 5fb7ba5..c8fed9e 100644 --- a/backend/server/api/mail/scan-internal.post.ts +++ b/backend/server/api/mail/scan-internal.post.ts @@ -64,7 +64,10 @@ export default defineEventHandler(async (event) => { await deleteOldMailBlocked(userId); const config = useRuntimeConfig(event); - const msClientId: string = (config.msOauthClientId as string) || process.env.MS_OAUTH_CLIENT_ID || ""; + const oauthClientIds = { + msClientId: (config.msOauthClientId as string) || process.env.MS_OAUTH_CLIENT_ID || "", + googleClientId: (config.googleOauthClientId as string) || process.env.GOOGLE_OAUTH_CLIENT_ID || "", + }; let totalScanned = 0; let totalBlocked = 0; @@ -72,7 +75,7 @@ export default defineEventHandler(async (event) => { for (const connection of eligibleConnections) { let imapAuth: { user: string; accessToken: string } | { user: string; pass: string }; try { - imapAuth = await resolveImapAuth(connection, msClientId); + imapAuth = await resolveImapAuth(connection, oauthClientIds); } catch { continue; } diff --git a/backend/server/api/mail/scan.post.ts b/backend/server/api/mail/scan.post.ts index dc0a64b..6b9a8b8 100644 --- a/backend/server/api/mail/scan.post.ts +++ b/backend/server/api/mail/scan.post.ts @@ -55,7 +55,10 @@ export default defineEventHandler(async (event) => { await deleteOldMailBlocked(user.id); const config = useRuntimeConfig(event); - const msClientId: string = (config.msOauthClientId as string) || process.env.MS_OAUTH_CLIENT_ID || ""; + const oauthClientIds = { + msClientId: (config.msOauthClientId as string) || process.env.MS_OAUTH_CLIENT_ID || "", + googleClientId: (config.googleOauthClientId as string) || process.env.GOOGLE_OAUTH_CLIENT_ID || "", + }; let totalScanned = 0; let totalBlocked = 0; @@ -63,7 +66,7 @@ export default defineEventHandler(async (event) => { for (const connection of eligibleConnections) { let imapAuth: { user: string; accessToken: string } | { user: string; pass: string }; try { - imapAuth = await resolveImapAuth(connection, msClientId); + imapAuth = await resolveImapAuth(connection, oauthClientIds); } catch { continue; } diff --git a/backend/server/db/mail.ts b/backend/server/db/mail.ts index 1933a07..01f62ff 100644 --- a/backend/server/db/mail.ts +++ b/backend/server/db/mail.ts @@ -1,6 +1,7 @@ import { usePrisma } from "../utils/prisma"; import { encrypt, decrypt } from "../utils/crypto"; import { refreshMicrosoftTokens } from "../utils/ms-oauth"; +import { refreshGoogleTokens } from "../utils/google-oauth"; export async function getMailConnections(userId: string) { const db = usePrisma(); @@ -594,24 +595,81 @@ export async function upsertOauthMicrosoftConnection(params: { }); } +/** + * Creates or updates a MailConnection for Google OAuth (Gmail). + * Analog zu upsertOauthMicrosoftConnection — gleiche unique-key-Logik (userId+email). + * authMethod='oauth2_google' ist der Diskriminator. + * IMAP: imap.gmail.com:993, XOAUTH2 via ImapFlow { user, accessToken }. + */ +export async function upsertOauthGoogleConnection(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: "Gmail", + imapHost: "imap.gmail.com", + imapPort: 993, + passwordEncrypted: "", // not used for oauth + rejectUnauthorized: true, + useStarttls: false, + isActive: true, + authMethod: "oauth2_google", + oauthAccessToken: params.encryptedAccessToken, + oauthRefreshToken: params.encryptedRefreshToken, + oauthTokenExpiry: params.tokenExpiry, + oauthScope: params.scope, + }, + update: { + providerName: "Gmail", + imapHost: "imap.gmail.com", + imapPort: 993, + authMethod: "oauth2_google", + oauthAccessToken: params.encryptedAccessToken, + oauthRefreshToken: params.encryptedRefreshToken, + oauthTokenExpiry: params.tokenExpiry, + oauthScope: params.scope, + isActive: true, + lastConnectError: null, + lastConnectErrorAt: null, + }, + }); +} + // ─── Token Refresh with Race-Condition Protection ───────────────────────────── /** - * Refreshes the Microsoft OAuth tokens for a given MailConnection and persists them. + * Refreshes OAuth tokens (Microsoft or Google) for a given MailConnection and persists them. + * + * Unterstützte authMethods: 'oauth2_microsoft', 'oauth2_google'. + * clientId muss zum jeweiligen Provider gehören: + * - oauth2_microsoft → Azure App Registration Client ID (MS_OAUTH_CLIENT_ID) + * - oauth2_google → Google Cloud OAuth Client ID (GOOGLE_OAUTH_CLIENT_ID) * * Race-Condition strategy (Optimistic Concurrency): * 1. Read current oauth_token_expiry from DB. - * 2. POST to MS token endpoint to get fresh tokens. + * 2. POST to provider token endpoint to get fresh tokens. * 3. UPDATE with WHERE oauth_token_expiry = (optimistic lock). * 4. If affected_rows = 0: another process refreshed concurrently. * → Read the freshly stored access_token and return it WITHOUT re-refreshing. - * This avoids a double-refresh loop that would invalidate the new refresh_token. + * This avoids a double-refresh loop that would invalidate the new refresh_token + * (critical for Microsoft which rotates refresh_tokens; Google does not rotate). * * 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) + * - Connection not found or not an OAuth method + * - Token refresh fails (invalid/revoked refresh_token) */ export async function refreshAndSaveTokens( connectionId: string, @@ -619,10 +677,14 @@ export async function refreshAndSaveTokens( ): Promise { const db = usePrisma(); - // Step 1: Read current token state + // Step 1: Read current token state (both oauth methods) const conn = await db.mailConnection.findFirst({ - where: { id: connectionId, authMethod: "oauth2_microsoft" }, + where: { + id: connectionId, + authMethod: { in: ["oauth2_microsoft", "oauth2_google"] }, + }, select: { + authMethod: true, oauthRefreshToken: true, oauthAccessToken: true, oauthTokenExpiry: true, @@ -636,11 +698,21 @@ export async function refreshAndSaveTokens( const currentExpiry = conn.oauthTokenExpiry; const decryptedRefreshToken = decrypt(conn.oauthRefreshToken); - // Step 2: Refresh at MS - const fresh = await refreshMicrosoftTokens({ - clientId, - refreshToken: decryptedRefreshToken, - }); + // Step 2: Refresh at the appropriate provider endpoint + let fresh: { access_token: string; refresh_token: string; expires_in: number }; + + if (conn.authMethod === "oauth2_google") { + fresh = await refreshGoogleTokens({ + clientId, + refreshToken: decryptedRefreshToken, + }); + } else { + // oauth2_microsoft + fresh = await refreshMicrosoftTokens({ + clientId, + refreshToken: decryptedRefreshToken, + }); + } const newExpiry = new Date(Date.now() + fresh.expires_in * 1000); const encryptedNewAccess = encrypt(fresh.access_token); @@ -683,8 +755,8 @@ export async function refreshAndSaveTokens( /** * 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. + * Used by [id].delete.ts for the revoke flow (both Microsoft and Google). + * Returns null if no refresh_token is stored or authMethod is not OAuth-based. */ export async function getDecryptedRefreshToken( connectionId: string, @@ -692,7 +764,11 @@ export async function getDecryptedRefreshToken( ): Promise { const db = usePrisma(); const conn = await db.mailConnection.findFirst({ - where: { id: connectionId, userId, authMethod: "oauth2_microsoft" }, + where: { + id: connectionId, + userId, + authMethod: { in: ["oauth2_microsoft", "oauth2_google"] }, + }, select: { oauthRefreshToken: true }, }); diff --git a/backend/server/utils/google-oauth.ts b/backend/server/utils/google-oauth.ts new file mode 100644 index 0000000..21e2fb7 --- /dev/null +++ b/backend/server/utils/google-oauth.ts @@ -0,0 +1,198 @@ +/** + * Google Identity Platform — OAuth2 PKCE utilities. + * + * Scope: https://mail.google.com/ (full IMAP access, XOAUTH2) + * Public Client / No Client-Secret — PKCE (S256) same as MS-OAuth. + * + * Google-Specifics vs. Microsoft: + * - Token endpoint: accounts.google.com (not login.microsoftonline.com) + * - refresh_token kommt NUR beim ersten Consent zurück. Daher: prompt=consent + * + access_type=offline PFLICHT bei jedem init. Ohne das: kein refresh_token + * → Connection lebt nur 1h (access_token TTL). + * - Google rotiert refresh_tokens NICHT bei Nutzung (anders als MS). Ein einmal + * ausgestelltes refresh_token gilt bis es manuell revoked wird. + * - Email kommt aus id_token Claim `email` (immer gesetzt wenn openid-Scope vorhanden). + * - IMAP: imap.gmail.com:993, XOAUTH2 via ImapFlow { user, accessToken }. + * + * See: https://developers.google.com/identity/protocols/oauth2/native-app + */ + +import { createHash, randomBytes } from "crypto"; + +const GOOGLE_TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token"; +export const GOOGLE_AUTH_BASE = "https://accounts.google.com/o/oauth2/v2/auth"; + +/** + * Scopes angefragt. + * https://mail.google.com/ = full IMAP access (READ + DELETE + EXPUNGE). + * openid + email = id_token mit email-Claim für User-Identifikation. + */ +export const GOOGLE_OAUTH_SCOPES = [ + "https://mail.google.com/", + "openid", + "email", +].join(" "); + +/** + * Redirect-URI: identisch zu MS (gleiche Deep-Link-Route in der App). + * Muss in der Google Cloud Console unter "Authorised redirect URIs" registriert sein. + * Android: als Custom URI Scheme (nicht als Android App Link, PKCE-Flow). + */ +export const GOOGLE_REDIRECT_URI = "rebreak://auth/mail-oauth-callback"; + +// ── PKCE Helpers ────────────────────────────────────────────────────────────── +// Identisch zu ms-oauth.ts — ausgelagert weil beide Provider PKCE S256 nutzen. + +/** Generates a cryptographically random code_verifier (43-128 chars, URL-safe). */ +export function generateCodeVerifier(): string { + 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 GoogleTokenResponse { + access_token: string; + /** + * KRITISCH: Google gibt refresh_token NUR beim ersten Consent zurück + * (prompt=consent in init). Bei nachfolgendem Re-Connect ohne prompt=consent: + * refresh_token fehlt in der Response. Daher immer prompt=consent verwenden. + */ + refresh_token?: string; + /** Sekunden bis access_token abläuft (typisch 3600 für Google). */ + expires_in: number; + scope: string; + token_type: string; + /** JWT mit user-claims (email, sub). Kommt weil openid-Scope angefragt. */ + id_token?: string; +} + +/** + * Exchanges an authorization_code for tokens. + * Wirft wenn: HTTP-Fehler, access_token fehlt, oder refresh_token fehlt + * (letzteres ist ein Config-Fehler — prompt=consent + access_type=offline vergessen). + */ +export async function exchangeCodeForTokens(params: { + clientId: string; + code: string; + codeVerifier: string; +}): Promise { + const body = new URLSearchParams({ + grant_type: "authorization_code", + client_id: params.clientId, + code: params.code, + redirect_uri: GOOGLE_REDIRECT_URI, + code_verifier: params.codeVerifier, + }); + + const res = await fetch(GOOGLE_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(`Google token exchange failed (${res.status}): ${errText}`); + } + + const data = await res.json() as GoogleTokenResponse; + + if (!data.access_token) { + throw new Error("Google token response missing access_token"); + } + + if (!data.refresh_token) { + // Passiert wenn init NICHT mit prompt=consent aufgerufen wurde. + // Oder wenn der User die App-Permission schon mal erteilt hat und + // prompt=consent übersprungen wurde. Lösung: User muss in Google + // Account-Settings die App-Berechtigung revoken und neu connecten. + throw new Error( + "Google token response missing refresh_token. " + + "Ensure init uses prompt=consent and access_type=offline. " + + "If user previously granted access, they must revoke and re-authorize.", + ); + } + + return data as GoogleTokenResponse & { refresh_token: string }; +} + +/** + * Refreshes a Google access_token using a refresh_token. + * + * Google-Spezifik: Google rotiert refresh_tokens NICHT bei normalem Einsatz. + * Das ausgestellte refresh_token ist langlebig. Ausnahmen: + * - User revoked App-Permission in Google-Account-Settings + * - 6-Monate Inaktivität (Google's policy) + * - Max. 50 refresh_tokens pro Client+User (älteste werden invalidiert) + * + * Im Gegensatz zu MS: response.refresh_token kann absent sein — dann den + * bestehenden behalten (kein Rotations-Problem wie bei MS AADSTS70043). + */ +export async function refreshGoogleTokens(params: { + clientId: string; + refreshToken: string; +}): Promise<{ + access_token: string; + /** Google gibt bei Refresh keinen neuen refresh_token — Original behalten. */ + refresh_token: string; + expires_in: number; +}> { + const body = new URLSearchParams({ + grant_type: "refresh_token", + client_id: params.clientId, + refresh_token: params.refreshToken, + }); + + const res = await fetch(GOOGLE_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(`Google token refresh failed (${res.status}): ${errText}`); + } + + const data = await res.json() as GoogleTokenResponse; + + if (!data.access_token) { + throw new Error("Google refresh response missing access_token"); + } + + return { + access_token: data.access_token, + // Google gibt bei Refresh keinen neuen refresh_token — behalte Original. + refresh_token: params.refreshToken, + expires_in: data.expires_in, + }; +} + +/** + * Extracts the email claim from a Google ID-token (JWT). + * Google garantiert `email`-Claim wenn openid+email scope vorhanden. + * Keine Signatur-Verifikation nötig (direkt vom Token-Endpoint über HTTPS empfangen). + */ +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"), + ); + // Google setzt `email` direkt — kein preferred_username-Fallback nötig. + return payload.email ?? null; + } catch { + return null; + } +} diff --git a/backend/server/utils/mail-auth.ts b/backend/server/utils/mail-auth.ts index be6a3ef..f4add61 100644 --- a/backend/server/utils/mail-auth.ts +++ b/backend/server/utils/mail-auth.ts @@ -19,26 +19,44 @@ export type ImapAuth = | { user: string; accessToken: string } | { user: string; pass: string }; +export interface OauthClientIds { + /** Azure App Registration Client ID (MS_OAUTH_CLIENT_ID). */ + msClientId: string; + /** Google Cloud OAuth Client ID (GOOGLE_OAUTH_CLIENT_ID). */ + googleClientId: string; +} + /** * Gibt das korrekte `auth`-Objekt für ImapFlow zurück. * * - oauth2_microsoft: Access-Token decrypten, bei Ablauf via MS-Endpoint refreshen. * Nutzt refreshAndSaveTokens() aus db/mail (Race-Condition-sicher, Prisma-basiert). + * - oauth2_google: Analog zu oauth2_microsoft. Google rotiert refresh_tokens nicht, + * aber der 5-Minuten-Puffer-Refresh gilt trotzdem (access_token ist 1h gültig). * - Alle anderen authMethods (app_password, default): passwordEncrypted decrypten. * * Wirft wenn: * - App-Password leer oder decrypt fehlschlägt * - OAuth-Token fehlt und kein Refresh möglich - * - refreshAndSaveTokens() wirft (revoked refresh_token, MS-Endpoint-Fehler) + * - refreshAndSaveTokens() wirft (revoked refresh_token, Provider-Endpoint-Fehler) * * @param connection MailConnection-Felder (Subset) - * @param clientId MS Azure App Registration Client-ID (nur für OAuth-Pfad) + * @param clientIds Beide OAuth Client-IDs; die richtige wird anhand authMethod gewählt. */ export async function resolveImapAuth( connection: MailConnectionAuthFields, - clientId: string, + clientIds: OauthClientIds, ): Promise { - if (connection.authMethod === "oauth2_microsoft") { + if ( + connection.authMethod === "oauth2_microsoft" || + connection.authMethod === "oauth2_google" + ) { + // Wähle den richtigen Client-ID anhand des authMethod-Diskriminators. + const clientId = + connection.authMethod === "oauth2_google" + ? clientIds.googleClientId + : clientIds.msClientId; + // Token-Expiry-Check: 5-Minuten-Puffer damit der Scan nicht // mitten in einem großen Mailbox-Durchlauf mit abgelaufenem Token stirbt. const fiveMinFromNow = Date.now() + 5 * 60 * 1000; @@ -48,13 +66,13 @@ export async function resolveImapAuth( let accessToken: string; if (isExpiredOrMissing) { - // Wirft wenn Refresh-Token fehlt oder MS-Endpoint antwortet mit Fehler. + // Wirft wenn Refresh-Token fehlt oder Provider-Endpoint antwortet mit Fehler. // Caller (scan.post / scan-internal.post) soll per try/catch continue-n. accessToken = await refreshAndSaveTokens(connection.id, clientId); } else { if (!connection.oauthAccessToken) { throw new Error( - `oauth2_microsoft connection ${connection.id} has no oauthAccessToken stored`, + `${connection.authMethod} connection ${connection.id} has no oauthAccessToken stored`, ); } accessToken = decrypt(connection.oauthAccessToken); @@ -63,7 +81,7 @@ export async function resolveImapAuth( return { user: connection.email, accessToken }; } - // App-Password-Pfad (gmail, icloud, gmx, yahoo, custom) + // App-Password-Pfad (gmail app-password, icloud, gmx, yahoo, custom) if (!connection.passwordEncrypted) { throw new Error( `Connection ${connection.id} has no passwordEncrypted (authMethod=${connection.authMethod})`,