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:
George Powell
2026-03-25 01:39:24 -04:00
parent 321fac9aa8
commit db04da6a2c
12 changed files with 1654 additions and 1 deletions
+1
View File
@@ -99,6 +99,7 @@ export async function createUser(anonymousId: string, email: string, passwordHas
email,
passwordHash,
appleId: null,
googleId: null,
firstName: firstName || null,
lastName: lastName || null,
isPrivate: false
+7 -1
View File
@@ -28,7 +28,7 @@ export async function validateSessionToken(token: string) {
const [result] = await db
.select({
// Adjust user table here to tweak returned data
user: { id: table.user.id, email: table.user.email, firstName: table.user.firstName, lastName: table.user.lastName, appleId: table.user.appleId },
user: { id: table.user.id, email: table.user.email, firstName: table.user.firstName, lastName: table.user.lastName, appleId: table.user.appleId, googleId: table.user.googleId },
session: table.session
})
.from(table.session)
@@ -99,6 +99,7 @@ export async function createUser(anonymousId: string, email: string, passwordHas
email,
passwordHash,
appleId: null,
googleId: null,
firstName: firstName || null,
lastName: lastName || null,
isPrivate: false
@@ -117,6 +118,11 @@ export async function getUserByAppleId(appleId: string) {
return user || null;
}
export async function getUserByGoogleId(googleId: string) {
const [user] = await db.select().from(table.user).where(eq(table.user.googleId, googleId));
return user || null;
}
export async function migrateAnonymousStats(anonymousId: string | undefined, userId: string) {
if (!anonymousId || anonymousId === userId) return;
+1
View File
@@ -7,6 +7,7 @@ export const user = sqliteTable('user', {
email: text('email').unique(),
passwordHash: text('password_hash'),
appleId: text('apple_id').unique(),
googleId: text('google_id').unique(),
isPrivate: integer('is_private', { mode: 'boolean' }).default(false)
});
+65
View File
@@ -0,0 +1,65 @@
const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
export function getGoogleAuthUrl(state: string): string {
const params = new URLSearchParams({
client_id: Bun.env.GOOGLE_CLIENT_ID!,
redirect_uri: `${Bun.env.PUBLIC_SITE_URL}/auth/google/callback`,
response_type: 'code',
scope: 'openid email profile',
state,
access_type: 'online',
prompt: 'select_account'
});
return `${GOOGLE_AUTH_URL}?${params.toString()}`;
}
export async function exchangeGoogleCode(
code: string,
redirectUri: string
): Promise<{
access_token: string;
token_type: string;
expires_in: number;
id_token: string;
scope: string;
}> {
const params = new URLSearchParams({
client_id: Bun.env.GOOGLE_CLIENT_ID!,
client_secret: Bun.env.GOOGLE_CLIENT_SECRET!,
code,
grant_type: 'authorization_code',
redirect_uri: redirectUri
});
const response = await fetch(GOOGLE_TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params.toString()
});
if (!response.ok) {
const errText = await response.text();
throw new Error(`Google token exchange failed: ${errText}`);
}
return await response.json();
}
/**
* Decode Google's id_token JWT payload without signature verification.
* Safe because the token is received directly from Google's token endpoint over TLS.
*/
export function decodeGoogleIdToken(idToken: string): {
sub: string;
email?: string;
email_verified?: boolean;
name?: string;
given_name?: string;
family_name?: string;
} {
const [, payloadB64] = idToken.split('.');
const padded = payloadB64 + '='.repeat((4 - (payloadB64.length % 4)) % 4);
const payload = JSON.parse(atob(padded.replace(/-/g, '+').replace(/_/g, '/')));
return payload;
}