import { NextRequest, NextResponse } from 'next/server'; import { db } from '@/lib/db'; import { appendExpiredCookies, getAuthCookieName, getAuthCookieOptions, getCookieDomain, getFlowCookieOptions, } from '@/lib/cookieConfig'; import { appUrl, urlForPath, wwwUrl } from '@/lib/hosts'; import { signUserId } from '@/lib/session'; import { appendRedirectParam, GOOGLE_OAUTH_STATE_COOKIE_NAME, POST_AUTH_REDIRECT_COOKIE_NAME, sanitizeRedirectPath, } from '@/lib/auth-flow'; import { ATTRIBUTION_COOKIE_NAME, getEmailDomain, parseAttributionCookie, shouldResumeOnboarding, } from '@/lib/revops'; import { triggerLifecycleScoring } from '@/lib/revops-server'; export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url); const code = searchParams.get('code'); const state = searchParams.get('state'); const firstTouch = parseAttributionCookie(request.cookies.get(ATTRIBUTION_COOKIE_NAME)?.value); const savedOauthState = request.cookies.get(GOOGLE_OAUTH_STATE_COOKIE_NAME)?.value; const savedRedirect = sanitizeRedirectPath(request.cookies.get(POST_AUTH_REDIRECT_COOKIE_NAME)?.value); // If no code, redirect to Google OAuth if (!code) { const googleClientId = process.env.GOOGLE_CLIENT_ID; if (!googleClientId) { return NextResponse.json( { error: 'Google Client ID not configured' }, { status: 500 } ); } const redirectUri = appUrl('/api/auth/google'); const scope = 'openid email profile'; const redirectTarget = sanitizeRedirectPath(searchParams.get('redirect')); const oauthState = crypto.randomUUID(); const googleAuthUrl = new URL('https://accounts.google.com/o/oauth2/v2/auth'); googleAuthUrl.searchParams.set('client_id', googleClientId); googleAuthUrl.searchParams.set('redirect_uri', redirectUri); googleAuthUrl.searchParams.set('response_type', 'code'); googleAuthUrl.searchParams.set('scope', scope); googleAuthUrl.searchParams.set('state', oauthState); const response = NextResponse.redirect(googleAuthUrl); response.cookies.set(GOOGLE_OAUTH_STATE_COOKIE_NAME, oauthState, getFlowCookieOptions(60 * 10)); if (redirectTarget) { response.cookies.set(POST_AUTH_REDIRECT_COOKIE_NAME, redirectTarget, getFlowCookieOptions(60 * 10)); } else { response.cookies.delete({ name: POST_AUTH_REDIRECT_COOKIE_NAME, path: '/', domain: getCookieDomain(), }); } return response; } // Handle callback with code try { if (!state || !savedOauthState || state !== savedOauthState) { const invalidStateResponse = NextResponse.redirect( wwwUrl('/login?error=google-state-invalid') ); invalidStateResponse.cookies.delete({ name: GOOGLE_OAUTH_STATE_COOKIE_NAME, path: '/', domain: getCookieDomain() }); invalidStateResponse.cookies.delete({ name: POST_AUTH_REDIRECT_COOKIE_NAME, path: '/', domain: getCookieDomain() }); return invalidStateResponse; } const googleClientId = process.env.GOOGLE_CLIENT_ID; const googleClientSecret = process.env.GOOGLE_CLIENT_SECRET; if (!googleClientId || !googleClientSecret) { return NextResponse.json( { error: 'Google OAuth not configured' }, { status: 500 } ); } const redirectUri = appUrl('/api/auth/google'); // Exchange code for tokens const tokenResponse = await fetch('https://oauth2.googleapis.com/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, body: new URLSearchParams({ code, client_id: googleClientId, client_secret: googleClientSecret, redirect_uri: redirectUri, grant_type: 'authorization_code', }), }); if (!tokenResponse.ok) { throw new Error('Failed to exchange code for tokens'); } const tokens = await tokenResponse.json(); // Get user info from Google const userInfoResponse = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', { headers: { Authorization: `Bearer ${tokens.access_token}`, }, }); if (!userInfoResponse.ok) { throw new Error('Failed to get user info'); } const userInfo = await userInfoResponse.json(); // Check if user exists in database let user = await db.user.findUnique({ where: { email: userInfo.email }, }); const isNewUser = !user; // Create user if they don't exist if (!user) { const onboardingStartedAt = new Date(); user = await db.user.create({ data: { email: userInfo.email, name: userInfo.name || userInfo.email.split('@')[0], image: userInfo.picture, emailVerified: new Date(), // Google already verified the email password: null, // OAuth users don't need a password onboardingStartedAt, emailDomain: getEmailDomain(userInfo.email), signupSource: firstTouch?.signupSource || null, signupMedium: firstTouch?.signupMedium || null, signupCampaign: firstTouch?.signupCampaign || null, signupContent: firstTouch?.signupContent || null, signupTerm: firstTouch?.signupTerm || null, signupReferrer: firstTouch?.signupReferrer || null, signupLandingPath: firstTouch?.signupLandingPath || '/signup', signupFirstSeenAt: firstTouch?.signupFirstSeenAt ? new Date(firstTouch.signupFirstSeenAt) : onboardingStartedAt, }, }); // Create Account entry for the OAuth provider await db.account.create({ data: { userId: user.id, type: 'oauth', provider: 'google', providerAccountId: userInfo.sub || userInfo.id, access_token: tokens.access_token, refresh_token: tokens.refresh_token, expires_at: tokens.expires_in ? Math.floor(Date.now() / 1000) + tokens.expires_in : null, token_type: tokens.token_type, scope: tokens.scope, id_token: tokens.id_token, }, }); } else { // Update existing account tokens const existingAccount = await db.account.findUnique({ where: { provider_providerAccountId: { provider: 'google', providerAccountId: userInfo.sub || userInfo.id, }, }, }); if (existingAccount) { await db.account.update({ where: { id: existingAccount.id }, data: { access_token: tokens.access_token, refresh_token: tokens.refresh_token, expires_at: tokens.expires_in ? Math.floor(Date.now() / 1000) + tokens.expires_in : null, }, }); } else { // Create Account entry if it doesn't exist await db.account.create({ data: { userId: user.id, type: 'oauth', provider: 'google', providerAccountId: userInfo.sub || userInfo.id, access_token: tokens.access_token, refresh_token: tokens.refresh_token, expires_at: tokens.expires_in ? Math.floor(Date.now() / 1000) + tokens.expires_in : null, token_type: tokens.token_type, scope: tokens.scope, id_token: tokens.id_token, }, }); } } triggerLifecycleScoring(user.id, isNewUser ? 'signup' : 'subscription_changed'); const onboardingTarget = isNewUser || shouldResumeOnboarding(user) ? appendRedirectParam('/onboarding', savedRedirect, { authMethod: 'google', isNewUser: isNewUser.toString(), }) : (savedRedirect || appendRedirectParam('/dashboard', null, { authMethod: 'google', isNewUser: isNewUser.toString(), })); const redirectUrl = new URL(urlForPath(onboardingTarget)); const response = NextResponse.redirect(redirectUrl.toString()); response.cookies.set(getAuthCookieName(), signUserId(user.id), getAuthCookieOptions()); response.cookies.delete({ name: GOOGLE_OAUTH_STATE_COOKIE_NAME, path: '/', domain: getCookieDomain() }); response.cookies.delete({ name: POST_AUTH_REDIRECT_COOKIE_NAME, path: '/', domain: getCookieDomain() }); // Must stay after the last cookies.set()/delete() call - see appendExpiredCookies. // The attribution cookie lives 90 days, so a pre-COOKIE_DOMAIN host-only copy can // still be around and has to be expired alongside the domain-scoped one. appendExpiredCookies(response.headers, [{ name: ATTRIBUTION_COOKIE_NAME, httpOnly: false }]); return response; } catch (error) { console.error('Google OAuth error:', error); const errorResponse = NextResponse.redirect( wwwUrl('/login?error=google-signin-failed') ); errorResponse.cookies.delete({ name: GOOGLE_OAUTH_STATE_COOKIE_NAME, path: '/', domain: getCookieDomain() }); errorResponse.cookies.delete({ name: POST_AUTH_REDIRECT_COOKIE_NAME, path: '/', domain: getCookieDomain() }); return errorResponse; } }