Groundwork for testmodul.qrmaster.net, a second stack running the `test` branch on a real qrmaster.net subdomain. Production scopes its session cookie to .qrmaster.net, so the browser sends it to every subdomain including staging. With both environments naming the cookie `userId`, the browser holds two cookies of the same name and cookies.get() picks one arbitrarily - staging logins would look randomly signed-out. AUTH_COOKIE_NAME lets staging pick `userId_test` instead. Production keeps the `userId` default; changing it there would invalidate every existing session. Wired getAuthCookieName() into the six places that named the cookie literally. The account deletion route now expires both the host-only and the domain-scoped variant like the logout route already does, instead of a single cookies().delete() that would leave the other one behind. NEXT_PUBLIC_WWW_URL and NEXT_PUBLIC_APP_URL become build ARGs so the same image can be built pointing at the staging host - the defaults keep a plain production build byte identical to before. Like COOKIE_DOMAIN these must exist at build time, because process.env is inlined into the Edge middleware bundle. robots.ts now serves Disallow-all unless NEXT_PUBLIC_INDEXABLE is true. Staging otherwise returns the production robots.txt and invites crawlers to index a duplicate of www. docker-compose.test.yml is the staging overlay. Two things it must get right, both verified against `docker compose config`: - db and redis need `networks: !override`. Compose MERGES the networks mapping from the base file, and since qrmaster-network is external and shared, a plain list left them attached to it - `db` would then resolve to two containers and staging could read and write the production database. - The web entrypoint is replaced so `prisma migrate deploy` never runs. prisma/migrations stopped in April 2026 and the schema has moved on through manual SQL since, so applying them to a fresh database would build a stale schema. Staging gets its schema from `pg_dump --schema-only` against production instead. Verified: tsc clean, production build succeeds, and the merged compose config confirms staging keeps db/redis off the shared network while production resolves unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
246 lines
8.9 KiB
TypeScript
246 lines
8.9 KiB
TypeScript
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;
|
|
}
|
|
}
|