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>
89 lines
3.3 KiB
TypeScript
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 };
|
|
});
|