245 lines
8.5 KiB
TypeScript
245 lines
8.5 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server';
|
|
import { db } from '@/lib/db';
|
|
import { getAuthCookieOptions } from '@/lib/cookieConfig';
|
|
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';
|
|
|
|
const isProduction = process.env.NODE_ENV === 'production';
|
|
|
|
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 = `${process.env.NEXT_PUBLIC_APP_URL}/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, {
|
|
httpOnly: true,
|
|
secure: isProduction,
|
|
sameSite: 'lax',
|
|
path: '/',
|
|
maxAge: 60 * 10,
|
|
});
|
|
|
|
if (redirectTarget) {
|
|
response.cookies.set(POST_AUTH_REDIRECT_COOKIE_NAME, redirectTarget, {
|
|
httpOnly: true,
|
|
secure: isProduction,
|
|
sameSite: 'lax',
|
|
path: '/',
|
|
maxAge: 60 * 10,
|
|
});
|
|
} else {
|
|
response.cookies.delete(POST_AUTH_REDIRECT_COOKIE_NAME);
|
|
}
|
|
|
|
return response;
|
|
}
|
|
|
|
// Handle callback with code
|
|
try {
|
|
if (!state || !savedOauthState || state !== savedOauthState) {
|
|
const invalidStateResponse = NextResponse.redirect(
|
|
`${process.env.NEXT_PUBLIC_APP_URL}/login?error=google-state-invalid`
|
|
);
|
|
invalidStateResponse.cookies.delete(GOOGLE_OAUTH_STATE_COOKIE_NAME);
|
|
invalidStateResponse.cookies.delete(POST_AUTH_REDIRECT_COOKIE_NAME);
|
|
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 = `${process.env.NEXT_PUBLIC_APP_URL}/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(`${process.env.NEXT_PUBLIC_APP_URL}${onboardingTarget}`);
|
|
|
|
const response = NextResponse.redirect(redirectUrl.toString());
|
|
response.cookies.set('userId', user.id, getAuthCookieOptions());
|
|
response.cookies.delete(GOOGLE_OAUTH_STATE_COOKIE_NAME);
|
|
response.cookies.delete(POST_AUTH_REDIRECT_COOKIE_NAME);
|
|
response.cookies.delete(ATTRIBUTION_COOKIE_NAME);
|
|
return response;
|
|
} catch (error) {
|
|
console.error('Google OAuth error:', error);
|
|
const errorResponse = NextResponse.redirect(
|
|
`${process.env.NEXT_PUBLIC_APP_URL}/login?error=google-signin-failed`
|
|
);
|
|
errorResponse.cookies.delete(GOOGLE_OAUTH_STATE_COOKIE_NAME);
|
|
errorResponse.cookies.delete(POST_AUTH_REDIRECT_COOKIE_NAME);
|
|
return errorResponse;
|
|
}
|
|
}
|