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