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:
parent
2cb1f8ad6e
commit
96597ffaff
@ -6,8 +6,9 @@
|
||||
* 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
|
||||
@ -15,6 +16,7 @@
|
||||
* 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:
|
||||
@ -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.`,
|
||||
|
||||
@ -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 ?? "",
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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"}`,
|
||||
|
||||
184
backend/server/api/mail/oauth/google/callback.post.ts
Normal file
184
backend/server/api/mail/oauth/google/callback.post.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
88
backend/server/api/mail/oauth/google/init.post.ts
Normal file
88
backend/server/api/mail/oauth/google/init.post.ts
Normal 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 };
|
||||
});
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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({
|
||||
// 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 },
|
||||
});
|
||||
|
||||
|
||||
198
backend/server/utils/google-oauth.ts
Normal file
198
backend/server/utils/google-oauth.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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})`,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user