From a4fe444b037898289caa6d28d6890636d284e5b7 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Mon, 17 Nov 2025 08:46:43 +0000 Subject: [PATCH] Create Google OAuth callback endpoint with federation cgen-9e4a8de372824d889bd5f07f8fd4b3ec --- api/google/oauth/callback.ts | 264 +++++++++++++++++++++++++++++++++++ 1 file changed, 264 insertions(+) create mode 100644 api/google/oauth/callback.ts diff --git a/api/google/oauth/callback.ts b/api/google/oauth/callback.ts new file mode 100644 index 00000000..25040cb3 --- /dev/null +++ b/api/google/oauth/callback.ts @@ -0,0 +1,264 @@ +import { createClient } from "@supabase/supabase-js"; +import { notifyAccountLinked } from "../../_notifications.js"; +import { getAdminClient } from "../../_supabase.js"; +import { federateOAuthUser, linkProviderToPassport } from "../../_oauth-federation.js"; + +export const config = { + runtime: "nodejs", +}; + +interface GoogleTokenResponse { + access_token: string; + token_type: string; + expires_in: number; + id_token: string; +} + +interface GoogleUserInfo { + sub: string; + email: string; + name: string; + picture?: string; +} + +function decodeJWT(token: string): any { + try { + const parts = token.split("."); + if (parts.length !== 3) return null; + + const decoded = Buffer.from(parts[1], "base64").toString("utf-8"); + return JSON.parse(decoded); + } catch (e) { + return null; + } +} + +export default async function handler(req: any, res: any) { + if (req.method !== "GET") { + return res.status(405).json({ error: "Method not allowed" }); + } + + const { code, state, error } = req.query; + + // Handle Google error + if (error) { + return res.redirect(`/login?error=${error}`); + } + + if (!code) { + return res.redirect("/login?error=no_code"); + } + + // Parse state to determine if this is a linking or login flow + let isLinkingFlow = false; + let redirectTo = "/dashboard"; + + if (state) { + try { + const stateData = JSON.parse( + Buffer.from(state as string, "base64").toString("utf-8"), + ); + isLinkingFlow = stateData.action === "link"; + redirectTo = stateData.redirectTo || redirectTo; + } catch (e) { + console.log("[Google OAuth] Could not parse state:", e); + } + } + + // For linking flow, extract user ID from temporary session stored in database + let authenticatedUserId: string | null = null; + if (isLinkingFlow) { + try { + const stateData = JSON.parse( + Buffer.from(state as string, "base64").toString("utf-8"), + ); + const sessionToken = stateData.sessionToken; + + if (!sessionToken) { + console.error( + "[Google OAuth] No session token found in linking flow state", + ); + return res.redirect( + "/login?error=session_lost&message=Session%20expired.%20Please%20try%20linking%20Google%20again.", + ); + } + + // Query database for the temporary linking session + const tempAdminClient = getAdminClient(); + const { data: session, error: sessionError } = await tempAdminClient + .from("discord_linking_sessions") + .select("user_id") + .eq("session_token", sessionToken) + .gt("expires_at", new Date().toISOString()) + .single(); + + if (sessionError || !session) { + console.error( + "[Google OAuth] Linking session not found or expired", + sessionError, + ); + return res.redirect( + "/login?error=session_lost&message=Session%20expired.%20Please%20try%20linking%20Google%20again.", + ); + } + + authenticatedUserId = session.user_id; + console.log( + "[Google OAuth] Linking session found, user_id:", + authenticatedUserId, + ); + + // Clean up: delete the temporary session + await tempAdminClient + .from("discord_linking_sessions") + .delete() + .eq("session_token", sessionToken); + } catch (e) { + console.error("[Google OAuth] Error parsing/using session token:", e); + return res.redirect( + "/login?error=session_lost&message=Session%20error.%20Please%20try%20again.", + ); + } + } + + const clientId = process.env.GOOGLE_OAUTH_CLIENT_ID; + const clientSecret = process.env.GOOGLE_OAUTH_CLIENT_SECRET; + const supabaseUrl = process.env.VITE_SUPABASE_URL; + const supabaseServiceRole = process.env.SUPABASE_SERVICE_ROLE; + + if (!clientId || !clientSecret || !supabaseUrl || !supabaseServiceRole) { + console.error("[Google OAuth] Missing environment variables"); + return res.redirect("/login?error=config"); + } + + try { + const apiBase = process.env.VITE_API_BASE || "https://aethex.dev"; + const redirectUri = `${apiBase}/api/google/oauth/callback`; + + // Exchange code for access token + const tokenResponse = await fetch("https://oauth2.googleapis.com/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + grant_type: "authorization_code", + code, + redirect_uri: redirectUri, + }).toString(), + }); + + if (!tokenResponse.ok) { + const errorData = await tokenResponse.json(); + console.error("[Google OAuth] Token exchange failed:", errorData); + return res.redirect("/login?error=token_exchange"); + } + + const tokenData = (await tokenResponse.json()) as GoogleTokenResponse; + + if (!tokenData.access_token) { + console.error("[Google OAuth] No access token in response"); + return res.redirect("/login?error=no_token"); + } + + // Decode ID token to get user info + let googleUser: GoogleUserInfo | null = null; + + if (tokenData.id_token) { + googleUser = decodeJWT(tokenData.id_token) as GoogleUserInfo; + } + + // Fallback: Fetch user info from Google API if JWT decode failed + if (!googleUser) { + const userResponse = await fetch( + "https://www.googleapis.com/oauth2/v2/userinfo", + { + headers: { + Authorization: `Bearer ${tokenData.access_token}`, + }, + }, + ); + + if (!userResponse.ok) { + console.error("[Google OAuth] User fetch failed:", userResponse.status); + return res.redirect("/login?error=user_fetch"); + } + + googleUser = (await userResponse.json()) as GoogleUserInfo; + } + + if (!googleUser || !googleUser.email) { + console.error("[Google OAuth] No email in user info"); + return res.redirect( + "/login?error=no_email&message=Please+enable+email+on+your+Google+account", + ); + } + + const supabase = getAdminClient(); + + // LINKING FLOW: Link Google to authenticated user's Foundation Passport + if (isLinkingFlow && authenticatedUserId) { + console.log( + "[Google OAuth] Linking Google to user:", + authenticatedUserId, + ); + + try { + await linkProviderToPassport(authenticatedUserId, "google", { + id: googleUser.sub, + email: googleUser.email, + name: googleUser.name || undefined, + avatar: googleUser.picture || undefined, + }); + + console.log( + "[Google OAuth] Successfully linked Google to user:", + authenticatedUserId, + ); + + await notifyAccountLinked(authenticatedUserId, "Google"); + return res.redirect(redirectTo); + } catch (linkError: any) { + console.error("[Google OAuth] Linking failed:", linkError); + return res.redirect( + `/dashboard?error=link_failed&message=${encodeURIComponent(linkError?.message || "Failed to link Google account")}`, + ); + } + } + + // LOGIN FLOW: OAuth Federation + // Federate Google OAuth to Foundation Passport + try { + const federationResult = await federateOAuthUser("google", { + id: googleUser.sub, + email: googleUser.email, + name: googleUser.name || undefined, + avatar: googleUser.picture || undefined, + }); + + console.log("[Google OAuth] Federation result:", { + user_id: federationResult.user_id, + is_new_user: federationResult.is_new_user, + provider_linked: federationResult.provider_linked, + }); + + // Send notification if this is a new user + if (federationResult.is_new_user) { + await notifyAccountLinked(federationResult.user_id, "Google"); + } + + // Redirect to dashboard after successful federation + return res.redirect("/dashboard"); + } catch (federationError) { + console.error("[Google OAuth] Federation error:", federationError); + return res.redirect( + `/login?error=federation_failed&message=${encodeURIComponent("Failed to link Google account. Please try again.")}`, + ); + } + } catch (error) { + console.error("[Google OAuth] Callback error:", error); + res.redirect("/login?error=unknown"); + } +}