import { consumeOauthPendingState, countMailConnections, upsertOauthMicrosoftConnection, } from "../../../../db/mail"; import { encrypt } from "../../../../utils/crypto"; import { exchangeCodeForTokens, extractEmailFromIdToken, MS_OAUTH_SCOPES, } from "../../../../utils/ms-oauth"; /** * POST /api/mail/oauth/microsoft/callback * * Step 2 of the Microsoft OAuth PKCE flow. * * Called by the native app after it intercepts the deep-link redirect from Microsoft * (rebreak://auth/mail-oauth-callback?code=…&state=…). * * NO requireUser — the state parameter is the auth mechanism here (CSRF-safe because * only the user who initiated the flow has the stateId in their session). The userId * is retrieved from oauth_pending_states. * * Consent-Gate strategy (Hans-Müller-aligned): * We set consent_at = now() inline during this callback, because the user has * already passed through the ConnectMailSheet Art. 9 consent step before reaching * the Outlook-OAuth button. The consent_version is stamped as CURRENT_ART9_MAIL_VERSION. * This is the same pattern as [id].post.ts (password-based connect). * * Body: * code: string — authorization code from Microsoft * state: string — stateId from the init step (CSRF validation) * * Response: * 200: { connectionId, email, provider: 'outlook_oauth' } * 400: { error: 'invalid_body' } * 401: { error: 'invalid_state' } — state not found, expired, or already used * 500: { error: 'token_exchange_failed' } */ 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"; export default defineEventHandler(async (event) => { const config = useRuntimeConfig(event); const clientId = config.msOauthClientId as string; if (!clientId) { throw createError({ statusCode: 500, data: { error: "MS_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 ?? "microsoft_error", }, }); } const { access_token, refresh_token, expires_in, scope, id_token } = tokenResponse; // ── Extract email from ID-token ─────────────────────────────────────────── let outlookEmail: string | null = hintEmail ?? null; if (id_token) { const extracted = extractEmailFromIdToken(id_token); if (extracted) outlookEmail = extracted; } if (!outlookEmail) { throw createError({ statusCode: 502, data: { error: "email_extraction_failed", detail: "Could not extract email from Microsoft ID-token. Ensure openid+User.Read 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 upsertOauthMicrosoftConnection({ userId, email: outlookEmail, encryptedAccessToken, encryptedRefreshToken, tokenExpiry, scope: scope ?? MS_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: outlookEmail, provider: "outlook_oauth", title: null, }; });