Splits the two hostnames across one deployment. No files move: the Next app still serves every route on both hosts, and the middleware decides per host which paths it owns and 301s the rest. /login and /signup stay on www - all 82 marketing CTAs point at /signup, which carries a hard canonical to www plus ad traffic. src/lib/hosts.ts is the single source of truth for the boundary (APP_PATH_PREFIXES, isAppPath, wwwUrl, appUrl, urlForPath). The middleware and every absolute-URL builder read from it so they cannot drift apart. - Split the overloaded NEXT_PUBLIC_APP_URL into a www and an app origin. It previously fed both public URLs and in-app URLs, so any single value was wrong somewhere. Most important: QRCodeCard encodes this origin into the QR code the user downloads and prints, so it must stay on www. - Route Stripe return URLs, email links and OAuth redirects per path rather than against one origin, so /dashboard lands on app and /pricing on www. - Cross the host boundary once, after a successful login: the router cannot push across origins, so that jump needs a full load. The user arrives signed in because the session cookie is scoped to COOKIE_DOMAIN. - Keep the app host out of search indexes: X-Robots-Tag on every response plus a Disallow-all robots.txt via rewrite, and /sitemap.xml redirects to www. - Point the TikTok callback fallback at www explicitly. It used to read NEXT_PUBLIC_APP_URL, whose meaning changed here, and only the apex domain is verified with TikTok. Host splitting is inert while both origins are equal, so development is unaffected. Verified: tsc clean, production build succeeds including the Edge middleware bundle, and the path-to-host mapping is unit-checked (prefix traps like /created and /settings-guide stay on www, query strings do not break matching). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
245 lines
8.8 KiB
TypeScript
245 lines
8.8 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server';
|
|
import { db } from '@/lib/db';
|
|
import {
|
|
appendExpiredCookies,
|
|
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('userId', 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;
|
|
}
|
|
}
|