/** * 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 für Google iOS-OAuth-Client. * * Google verlangt für iOS-Client-Type eines von zwei Formaten: * a) Reverse-Client-ID: `com.googleusercontent.apps.:/oauth2redirect` (gewählt) * b) Bundle-ID: `org.rebreak.app:/oauth2redirect` * * Custom-Schemes wie `rebreak://...` (Microsoft-Pattern) werden mit * `invalid_request — doesn't comply with OAuth 2.0 policy` rejected. * Empirisch verifiziert 2026-05-28. * * iOS-App muss diesen URL-Scheme in CFBundleURLTypes registrieren. */ export const GOOGLE_REDIRECT_URI = "com.googleusercontent.apps.864178840836-i09oblmcel5q4rgggq9dids17mv9560u:/oauth2redirect"; // ── PKCE Helpers ────────────────────────────────────────────────────────────── // Identisch zu ms-oauth.ts — ausgelagert weil beide Provider PKCE S256 nutzen. /** Generates a cryptographically random code_verifier (43-128 chars, URL-safe). */ export function generateCodeVerifier(): string { return randomBytes(96).toString("base64url"); } /** Computes S256 code_challenge from the verifier. */ export function computeCodeChallenge(verifier: string): string { return createHash("sha256").update(verifier).digest("base64url"); } /** Generates a random state ID for CSRF protection (hex, 32 chars = 128 bits). */ export function generateStateId(): string { return randomBytes(16).toString("hex"); } // ── Token Exchange ───────────────────────────────────────────────────────────── export interface GoogleTokenResponse { access_token: string; /** * KRITISCH: Google gibt refresh_token NUR beim ersten Consent zurück * (prompt=consent in init). Bei nachfolgendem Re-Connect ohne prompt=consent: * refresh_token fehlt in der Response. Daher immer prompt=consent verwenden. */ refresh_token?: string; /** Sekunden bis access_token abläuft (typisch 3600 für Google). */ expires_in: number; scope: string; token_type: string; /** JWT mit user-claims (email, sub). Kommt weil openid-Scope angefragt. */ id_token?: string; } /** * Exchanges an authorization_code for tokens. * Wirft wenn: HTTP-Fehler, access_token fehlt, oder refresh_token fehlt * (letzteres ist ein Config-Fehler — prompt=consent + access_type=offline vergessen). */ export async function exchangeCodeForTokens(params: { clientId: string; code: string; codeVerifier: string; }): Promise { const body = new URLSearchParams({ grant_type: "authorization_code", client_id: params.clientId, code: params.code, redirect_uri: GOOGLE_REDIRECT_URI, code_verifier: params.codeVerifier, }); const res = await fetch(GOOGLE_TOKEN_ENDPOINT, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: body.toString(), }); if (!res.ok) { const errText = await res.text().catch(() => "unknown error"); throw new Error(`Google token exchange failed (${res.status}): ${errText}`); } const data = await res.json() as GoogleTokenResponse; if (!data.access_token) { throw new Error("Google token response missing access_token"); } if (!data.refresh_token) { // Passiert wenn init NICHT mit prompt=consent aufgerufen wurde. // Oder wenn der User die App-Permission schon mal erteilt hat und // prompt=consent übersprungen wurde. Lösung: User muss in Google // Account-Settings die App-Berechtigung revoken und neu connecten. throw new Error( "Google token response missing refresh_token. " + "Ensure init uses prompt=consent and access_type=offline. " + "If user previously granted access, they must revoke and re-authorize.", ); } return data as GoogleTokenResponse & { refresh_token: string }; } /** * Refreshes a Google access_token using a refresh_token. * * Google-Spezifik: Google rotiert refresh_tokens NICHT bei normalem Einsatz. * Das ausgestellte refresh_token ist langlebig. Ausnahmen: * - User revoked App-Permission in Google-Account-Settings * - 6-Monate Inaktivität (Google's policy) * - Max. 50 refresh_tokens pro Client+User (älteste werden invalidiert) * * Im Gegensatz zu MS: response.refresh_token kann absent sein — dann den * bestehenden behalten (kein Rotations-Problem wie bei MS AADSTS70043). */ export async function refreshGoogleTokens(params: { clientId: string; refreshToken: string; }): Promise<{ access_token: string; /** Google gibt bei Refresh keinen neuen refresh_token — Original behalten. */ refresh_token: string; expires_in: number; }> { const body = new URLSearchParams({ grant_type: "refresh_token", client_id: params.clientId, refresh_token: params.refreshToken, }); const res = await fetch(GOOGLE_TOKEN_ENDPOINT, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: body.toString(), }); if (!res.ok) { const errText = await res.text().catch(() => "unknown error"); throw new Error(`Google token refresh failed (${res.status}): ${errText}`); } const data = await res.json() as GoogleTokenResponse; if (!data.access_token) { throw new Error("Google refresh response missing access_token"); } return { access_token: data.access_token, // Google gibt bei Refresh keinen neuen refresh_token — behalte Original. refresh_token: params.refreshToken, expires_in: data.expires_in, }; } /** * Extracts the email claim from a Google ID-token (JWT). * Google garantiert `email`-Claim wenn openid+email scope vorhanden. * Keine Signatur-Verifikation nötig (direkt vom Token-Endpoint über HTTPS empfangen). */ export function extractEmailFromIdToken(idToken: string): string | null { try { const [, payloadB64] = idToken.split("."); if (!payloadB64) return null; const payload = JSON.parse( Buffer.from(payloadB64, "base64url").toString("utf8"), ); // Google setzt `email` direkt — kein preferred_username-Fallback nötig. return payload.email ?? null; } catch { return null; } }