OAuth Federation helper - links external OAuth providers to Foundation Passports
cgen-e1b376fce8314d75888ebbacc24dc778
This commit is contained in:
parent
ee59b17859
commit
56ea6aee97
1 changed files with 255 additions and 0 deletions
255
api/_oauth-federation.ts
Normal file
255
api/_oauth-federation.ts
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue