aethex-forge/api/_oauth-federation.ts
2025-11-17 08:47:57 +00:00

258 lines
7.2 KiB
TypeScript

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<FederationResult> {
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<string | null> {
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<void> {
// 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<string> {
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<void> {
// 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;
}
}