feat(mail): Gmail OAuth2 (XOAUTH2/PKCE) — replaces App-Password for Gmail

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 <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-05-28 15:13:21 +02:00
parent 2cb1f8ad6e
commit 96597ffaff
11 changed files with 783 additions and 60 deletions

View File

@ -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.`,

View File

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

View File

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

View File

@ -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"}`,

View File

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

View File

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

View File

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

View File

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

View File

@ -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 = <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.
* 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<string> {
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<string | null> {
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 },
});

View File

@ -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<GoogleTokenResponse & { refresh_token: string }> {
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;
}
}

View File

@ -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<ImapAuth> {
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})`,