chahinebrini 96597ffaff 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>
2026-05-28 15:13:21 +02:00

199 lines
7.2 KiB
TypeScript

/**
* 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;
}
}