import { createClient } from "@supabase/supabase-js"; const supabase = createClient( process.env.SUPABASE_URL || "", process.env.SUPABASE_SERVICE_ROLE || "", ); export interface OAuthUser { id: string; // OAuth provider user ID email: string; name?: string; avatar?: string; username?: string; } export interface FederationResult { user_id: string; // Foundation Passport user ID email: string; is_new_user: boolean; provider_linked: boolean; } /** * OAuth Federation: Link external OAuth providers to Foundation Passports * * This function implements the federation model where: * 1. User logs in with GitHub/Discord/Google/etc * 2. We check if a provider_identity exists for this OAuth user * 3. If yes, return the linked Foundation Passport * 4. If no, create a new Foundation Passport and link the provider * 5. All future logins with this provider link to the same Passport * * @param provider - OAuth provider name (github, discord, google, roblox, ethereum) * @param oauthUser - OAuth user data from the provider * @returns FederationResult with Foundation Passport user_id and linking status */ export async function federateOAuthUser( provider: string, oauthUser: OAuthUser, ): Promise { try { // Step 1: Check if this OAuth identity already exists const { data: existingIdentity, error: lookupError } = await supabase .from("provider_identities") .select("user_id") .eq("provider", provider) .eq("provider_user_id", oauthUser.id) .single(); if (lookupError && lookupError.code !== "PGRST116") { // PGRST116 = "no rows returned" (expected for new users) console.error("[OAuth Federation] Lookup error:", lookupError); throw lookupError; } // Step 2a: Existing provider identity - return the linked user if (existingIdentity) { return { user_id: existingIdentity.user_id, email: oauthUser.email, is_new_user: false, provider_linked: true, }; } // Step 2b: New provider identity - create Foundation Passport // Create a new user profile (Foundation Passport) const { data: newProfile, error: profileError } = await supabase .from("user_profiles") .insert({ email: oauthUser.email, full_name: oauthUser.name || oauthUser.username || "User", avatar_url: oauthUser.avatar || null, username: await generateUniqueUsername(oauthUser.username), created_at: new Date().toISOString(), }) .select("id") .single(); if (profileError || !newProfile) { console.error("[OAuth Federation] Profile creation error:", profileError); throw profileError || new Error("Failed to create user profile"); } // Step 3: Link the OAuth provider to the new Foundation Passport const { error: linkError } = await supabase .from("provider_identities") .insert({ user_id: newProfile.id, provider, provider_user_id: oauthUser.id, provider_email: oauthUser.email, provider_data: { username: oauthUser.username, avatar: oauthUser.avatar, name: oauthUser.name, }, }); if (linkError) { console.error("[OAuth Federation] Linking error:", linkError); // Clean up the profile if linking fails await supabase.from("user_profiles").delete().eq("id", newProfile.id); throw linkError; } // Step 4: Return the new user with linking success return { user_id: newProfile.id, email: oauthUser.email, is_new_user: true, provider_linked: true, }; } catch (error) { console.error("[OAuth Federation] Error:", error); throw error; } } /** * Check if a provider identity exists and return the linked Foundation Passport * Used for quick lookups during OAuth callbacks */ export async function getLinkedPassport( provider: string, provider_user_id: string, ): Promise { const { data, error } = await supabase .from("provider_identities") .select("user_id") .eq("provider", provider) .eq("provider_user_id", provider_user_id) .single(); if (error && error.code !== "PGRST116") { console.error("[OAuth Federation] Lookup error:", error); } return data?.user_id || null; } /** * Link an additional OAuth provider to an existing Foundation Passport * Used when a user wants to add GitHub/Discord to their existing account */ export async function linkProviderToPassport( user_id: string, provider: string, oauthUser: OAuthUser, ): Promise { // Check if this provider is already linked to a different user const { data: existingLink } = await supabase .from("provider_identities") .select("user_id") .eq("provider", provider) .eq("provider_user_id", oauthUser.id) .single(); if (existingLink && existingLink.user_id !== user_id) { throw new Error( `This ${provider} account is already linked to another AeThex account`, ); } // Insert the new provider link const { error } = await supabase.from("provider_identities").insert({ user_id, provider, provider_user_id: oauthUser.id, provider_email: oauthUser.email, provider_data: { username: oauthUser.username, avatar: oauthUser.avatar, name: oauthUser.name, }, }); if (error) { console.error("[OAuth Federation] Linking error:", error); throw error; } } /** * Generate a unique username from OAuth data * Fallback: use provider_username + random suffix */ async function generateUniqueUsername( preferredUsername?: string, ): Promise { let username = preferredUsername?.toLowerCase().replace(/[^a-z0-9_-]/g, "") || "user"; // Ensure minimum length if (username.length < 3) { username = "user" + Math.random().toString(36).substring(7); } // Check if username is already taken let attempts = 0; let finalUsername = username; while (attempts < 10) { const { data: existing } = await supabase .from("user_profiles") .select("id") .eq("username", finalUsername) .single(); if (!existing) { return finalUsername; } // Add a random suffix and try again finalUsername = `${username}${Math.random().toString(36).substring(7)}`; attempts++; } // Fallback to UUID-based username return `user_${Math.random().toString(36).substring(7)}`; } /** * Unlink a provider from a Foundation Passport * Ensures users can't unlink their last authentication method */ export async function unlinkProvider( user_id: string, provider: string, ): Promise { // Check how many providers this user has const { data: providers, error: listError } = await supabase .from("provider_identities") .select("provider") .eq("user_id", user_id); if (listError) { throw listError; } // Don't allow unlinking the last provider if (providers && providers.length <= 1) { throw new Error( "You must keep at least one login method. Add another provider before unlinking this one.", ); } // Unlink the provider const { error: deleteError } = await supabase .from("provider_identities") .delete() .eq("user_id", user_id) .eq("provider", provider); if (deleteError) { throw deleteError; } }