mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-04-05 17:33:31 -04:00
feat: add Sign In with Google
Adds Google OAuth alongside existing Apple and email/password auth. Follows the same patterns as Apple Sign-In: state cookie for CSRF, anonymousId migration, and user linking by email. Key differences: Google callback is a GET redirect (sameSite: lax) and uses a static client secret instead of a signed JWT. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
131
src/routes/auth/google/callback/+server.ts
Normal file
131
src/routes/auth/google/callback/+server.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { redirect, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { exchangeGoogleCode, decodeGoogleIdToken } from '$lib/server/google-auth';
|
||||
import * as auth from '$lib/server/auth';
|
||||
import { db } from '$lib/server/db';
|
||||
import { user as userTable } from '$lib/server/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
const code = url.searchParams.get('code');
|
||||
const state = url.searchParams.get('state');
|
||||
const errorParam = url.searchParams.get('error');
|
||||
|
||||
// User denied access
|
||||
if (errorParam) {
|
||||
redirect(302, '/');
|
||||
}
|
||||
|
||||
const storedRaw = cookies.get('google_oauth_state');
|
||||
if (!storedRaw || !state || !code) {
|
||||
throw error(400, 'Invalid OAuth callback');
|
||||
}
|
||||
|
||||
const stored = JSON.parse(storedRaw) as { state: string; anonymousId: string };
|
||||
if (stored.state !== state) {
|
||||
throw error(400, 'State mismatch');
|
||||
}
|
||||
cookies.delete('google_oauth_state', { path: '/' });
|
||||
|
||||
const anonId = stored.anonymousId;
|
||||
if (!anonId) {
|
||||
console.error('[Google auth] Missing anonymousId in state cookie');
|
||||
throw error(400, 'Missing anonymous ID — please return to the game and try again');
|
||||
}
|
||||
|
||||
// Exchange authorization code for tokens
|
||||
const tokens = await exchangeGoogleCode(
|
||||
code,
|
||||
`${Bun.env.PUBLIC_SITE_URL}/auth/google/callback`
|
||||
);
|
||||
const claims = decodeGoogleIdToken(tokens.id_token);
|
||||
const googleId = claims.sub;
|
||||
|
||||
// --- User resolution ---
|
||||
let userId: string;
|
||||
|
||||
// 1. Check if a user with this googleId already exists (returning user)
|
||||
const existingGoogleUser = await auth.getUserByGoogleId(googleId);
|
||||
|
||||
if (existingGoogleUser) {
|
||||
userId = existingGoogleUser.id;
|
||||
console.log(`[Google auth] Returning Google user: userId=${userId}, anonId=${anonId}`);
|
||||
await auth.migrateAnonymousStats(anonId, userId);
|
||||
} else if (claims.email) {
|
||||
// 2. Check if email matches an existing email/password or Apple user
|
||||
const existingEmailUser = await auth.getUserByEmail(claims.email);
|
||||
if (existingEmailUser) {
|
||||
// Link Google account to existing user
|
||||
await db.update(userTable).set({ googleId }).where(eq(userTable.id, existingEmailUser.id));
|
||||
userId = existingEmailUser.id;
|
||||
console.log(`[Google auth] Linked Google to existing email user: userId=${userId}, anonId=${anonId}`);
|
||||
await auth.migrateAnonymousStats(anonId, userId);
|
||||
} else {
|
||||
// 3. Brand new user — use anonymousId as user ID to preserve local stats
|
||||
userId = anonId;
|
||||
console.log(`[Google auth] New user (has email): userId=${userId}`);
|
||||
try {
|
||||
await db.insert(userTable).values({
|
||||
id: userId,
|
||||
email: claims.email,
|
||||
passwordHash: null,
|
||||
appleId: null,
|
||||
googleId,
|
||||
firstName: claims.given_name || null,
|
||||
lastName: claims.family_name || null,
|
||||
isPrivate: false
|
||||
});
|
||||
} catch (e: any) {
|
||||
// Handle race condition: if googleId was inserted between our check and insert
|
||||
if (e?.message?.includes('UNIQUE constraint')) {
|
||||
const retryUser = await auth.getUserByGoogleId(googleId);
|
||||
if (retryUser) {
|
||||
userId = retryUser.id;
|
||||
console.log(`[Google auth] Race condition (has email): resolved to userId=${userId}, anonId=${anonId}`);
|
||||
await auth.migrateAnonymousStats(anonId, userId);
|
||||
} else {
|
||||
throw error(500, 'Failed to create user');
|
||||
}
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No email from Google (edge case — Google almost always returns email)
|
||||
userId = anonId;
|
||||
console.log(`[Google auth] New user (no email): userId=${userId}`);
|
||||
try {
|
||||
await db.insert(userTable).values({
|
||||
id: userId,
|
||||
email: null,
|
||||
passwordHash: null,
|
||||
appleId: null,
|
||||
googleId,
|
||||
firstName: claims.given_name || null,
|
||||
lastName: claims.family_name || null,
|
||||
isPrivate: false
|
||||
});
|
||||
} catch (e: any) {
|
||||
if (e?.message?.includes('UNIQUE constraint')) {
|
||||
const retryUser = await auth.getUserByGoogleId(googleId);
|
||||
if (retryUser) {
|
||||
userId = retryUser.id;
|
||||
console.log(`[Google auth] Race condition (no email): resolved to userId=${userId}, anonId=${anonId}`);
|
||||
await auth.migrateAnonymousStats(anonId, userId);
|
||||
} else {
|
||||
throw error(500, 'Failed to create user');
|
||||
}
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create session
|
||||
const sessionToken = auth.generateSessionToken();
|
||||
const session = await auth.createSession(sessionToken, userId);
|
||||
auth.setSessionTokenCookie({ cookies } as any, sessionToken, session.expiresAt);
|
||||
|
||||
redirect(302, '/');
|
||||
};
|
||||
Reference in New Issue
Block a user