/** * Foundation OAuth Callback Handler * * Receives authorization code from Foundation, exchanges it for an access token, * fetches user information, and establishes a session on aethex.dev. * * Endpoint: GET /auth/callback?code=...&state=... * Token Exchange: POST /auth/callback/exchange */ import { VercelRequest, VercelResponse } from "@vercel/node"; import { getAdminClient } from "../_supabase"; const FOUNDATION_URL = process.env.VITE_FOUNDATION_URL || "https://aethex.foundation"; const CLIENT_ID = process.env.FOUNDATION_OAUTH_CLIENT_ID || "aethex_corp"; const CLIENT_SECRET = process.env.FOUNDATION_OAUTH_CLIENT_SECRET; const API_BASE = process.env.VITE_API_BASE || "https://aethex.dev"; interface FoundationTokenResponse { access_token: string; token_type: string; expires_in: number; scope: string; } interface FoundationUserInfo { id: string; email: string; username: string; full_name?: string; avatar_url?: string; profile_complete: boolean; achievements?: string[]; projects?: string[]; } /** * GET /auth/callback * Receives authorization code from Foundation */ export async function handleCallback(req: VercelRequest, res: VercelResponse) { if (req.method !== "GET") { return res.status(405).json({ error: "Method not allowed" }); } const { code, state, error, error_description } = req.query; // Handle Foundation errors if (error) { const message = error_description ? decodeURIComponent(String(error_description)) : String(error); return res.redirect( `/login?error=${error}&message=${encodeURIComponent(message)}`, ); } if (!code) { return res.redirect( `/login?error=no_code&message=${encodeURIComponent("No authorization code received")}`, ); } try { // Validate state token (CSRF protection) // In production, state should be validated by checking session/cookie if (!state) { console.warn("[Foundation OAuth] Missing state parameter"); } console.log( "[Foundation OAuth] Received authorization code, initiating token exchange", ); // Store code in a temporary location for the exchange endpoint // In a real implementation, you'd use a temporary token or session const exchangeResult = await performTokenExchange(String(code)); if (!exchangeResult.accessToken) { throw new Error("Failed to obtain access token"); } // Fetch user information from Foundation const userInfo = await fetchUserInfoFromFoundation( exchangeResult.accessToken, ); // Sync user to local database await syncUserToLocalDatabase(userInfo); // Set session cookies res.setHeader("Set-Cookie", [ `foundation_access_token=${exchangeResult.accessToken}; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=${exchangeResult.expiresIn}`, `auth_user_id=${userInfo.id}; Path=/; Secure; SameSite=Strict; Max-Age=2592000`, ]); console.log("[Foundation OAuth] User authenticated:", userInfo.id); // Redirect to dashboard (or stored destination) const redirectTo = (req.query.redirect_to as string) || "/dashboard"; return res.redirect(redirectTo); } catch (error) { console.error("[Foundation OAuth] Callback error:", error); const message = error instanceof Error ? error.message : "Authentication failed"; return res.redirect( `/login?error=auth_failed&message=${encodeURIComponent(message)}`, ); } } /** * POST /auth/callback/exchange * Exchange authorization code for access token * Called from frontend */ export async function handleTokenExchange( req: VercelRequest, res: VercelResponse, ) { if (req.method !== "POST") { return res.status(405).json({ error: "Method not allowed" }); } const { code } = req.body; if (!code) { return res.status(400).json({ error: "Authorization code is required" }); } try { const exchangeResult = await performTokenExchange(code); // Fetch user information from Foundation const userInfo = await fetchUserInfoFromFoundation( exchangeResult.accessToken, ); // Sync user to local database await syncUserToLocalDatabase(userInfo); // Set session cookies res.setHeader("Set-Cookie", [ `foundation_access_token=${exchangeResult.accessToken}; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=${exchangeResult.expiresIn}`, `auth_user_id=${userInfo.id}; Path=/; Secure; SameSite=Strict; Max-Age=2592000`, ]); console.log( "[Foundation OAuth] Token exchange successful for user:", userInfo.id, ); return res.status(200).json({ accessToken: exchangeResult.accessToken, user: userInfo, }); } catch (error) { console.error("[Foundation OAuth] Token exchange error:", error); const message = error instanceof Error ? error.message : "Token exchange failed"; return res.status(400).json({ error: message }); } } /** * Exchange authorization code for access token with Foundation */ async function performTokenExchange(code: string): Promise<{ accessToken: string; tokenType: string; expiresIn: number; }> { if (!CLIENT_SECRET) { throw new Error("FOUNDATION_OAUTH_CLIENT_SECRET not configured"); } // Get code verifier from request session or context // For now, Foundation might not require PKCE on backend exchange // But we'll prepare for it const tokenEndpoint = `${FOUNDATION_URL}/api/oauth/token`; console.log("[Foundation OAuth] Exchanging code at:", tokenEndpoint); const params = new URLSearchParams(); params.append("grant_type", "authorization_code"); params.append("code", code); params.append("client_id", CLIENT_ID); params.append("client_secret", CLIENT_SECRET); params.append("redirect_uri", `${API_BASE}/auth/callback`); // Note: If Foundation uses PKCE, code_verifier would be sent here // For now, assuming server-side clients don't need PKCE verifier const response = await fetch(tokenEndpoint, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", }, body: params.toString(), }); if (!response.ok) { const errorData = await response.json().catch(() => ({})); console.error("[Foundation OAuth] Token exchange failed:", errorData); throw new Error(`Token exchange failed: ${response.status}`); } const data = (await response.json()) as FoundationTokenResponse; if (!data.access_token) { throw new Error("No access token in Foundation response"); } return { accessToken: data.access_token, tokenType: data.token_type, expiresIn: data.expires_in || 3600, }; } /** * Fetch user information from Foundation using access token */ async function fetchUserInfoFromFoundation( accessToken: string, ): Promise { const userInfoEndpoint = `${FOUNDATION_URL}/api/oauth/userinfo`; console.log("[Foundation OAuth] Fetching user info from:", userInfoEndpoint); const response = await fetch(userInfoEndpoint, { method: "GET", headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/json", }, }); if (!response.ok) { throw new Error(`Failed to fetch user info: ${response.status}`); } const userInfo = (await response.json()) as FoundationUserInfo; if (!userInfo.id || !userInfo.email) { throw new Error("Invalid user info response from Foundation"); } return userInfo; } /** * Sync Foundation user to local database */ async function syncUserToLocalDatabase( foundationUser: FoundationUserInfo, ): Promise { const supabase = getAdminClient(); console.log( "[Foundation OAuth] Syncing user to local database:", foundationUser.id, ); // Upsert user profile const { error } = await supabase.from("user_profiles").upsert({ id: foundationUser.id, email: foundationUser.email, username: foundationUser.username || null, full_name: foundationUser.full_name || null, avatar_url: foundationUser.avatar_url || null, profile_completed: foundationUser.profile_complete || false, updated_at: new Date().toISOString(), }); if (error) { console.error("[Foundation OAuth] Failed to sync user profile:", error); throw new Error("Failed to create local user profile"); } console.log( "[Foundation OAuth] User synced successfully:", foundationUser.id, ); } /** * Export both handlers with different routing */ export default async function handler(req: VercelRequest, res: VercelResponse) { // Determine which handler to use based on method and path if (req.url?.includes("/exchange")) { return handleTokenExchange(req, res); } return handleCallback(req, res); }