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

89 lines
3.3 KiB
TypeScript

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