/** * Foundation OAuth Callback Handler * * CRITICAL: This is a READ-ONLY sync from Foundation. * aethex.dev acts as an OAuth client that caches Foundation-issued passports. * * Flow: * 1. User authenticates with Foundation (oauth server) * 2. Foundation redirects to this callback with authorization code * 3. Exchange code for access_token + user info from Foundation * 4. ONE-WAY SYNC: Upsert Foundation user data to local cache * 5. Create session on aethex.dev (Foundation passport is SSOT) * * IMPORTANT: We NEVER write to passport data except during this sync. * All mutations must flow through Foundation's APIs. * * 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 * * CRITICAL: This is the ONLY source of truth for passport data. * The returned userInfo is cached locally but never modified except during this sync. * * Security checkpoint: * - accessToken must have been obtained from Foundation's token endpoint * - userInfo response is directly from Foundation OAuth provider * - All data from here is trusted as authoritative identity */ async function fetchUserInfoFromFoundation( accessToken: string, ): Promise { const userInfoEndpoint = `${FOUNDATION_URL}/api/oauth/userinfo`; console.log( "[Foundation Passport Authority] Fetching user info from Foundation:", 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 passport from Foundation: ${response.status}`, ); } const userInfo = (await response.json()) as FoundationUserInfo; // Validate we got a real passport from Foundation if (!userInfo.id || !userInfo.email) { console.error( "[Foundation Passport Authority] Invalid passport received from Foundation", userInfo, ); throw new Error( "Invalid user passport response from Foundation. Missing required fields.", ); } console.log( "[Foundation Passport Authority] Valid passport received from Foundation:", { user_id: userInfo.id, username: userInfo.username, email: userInfo.email, }, ); return userInfo; } /** * Sync Foundation user to local database (READ-ONLY CACHE) * * This performs a ONE-WAY sync from Foundation to local cache. * We UPSERT data from Foundation, but NEVER modify the cache independently. * This cache is read-only except during this sync operation. * * If user data changes on Foundation, it syncs on next login. * If user data is modified locally outside this function: ERROR (should not happen) */ async function syncUserToLocalDatabase( foundationUser: FoundationUserInfo, ): Promise { const supabase = getAdminClient(); console.log( "[Foundation Passport Sync] Starting one-way sync from Foundation:", foundationUser.id, ); // ONE-WAY SYNC: Upsert Foundation data to local cache // Note: This is the ONLY place where user_profiles should be written to const now = new Date().toISOString(); const cacheValidUntil = new Date( Date.now() + 24 * 60 * 60 * 1000, ).toISOString(); // 24 hour cache TTL 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, // Sync metadata (critical for cache validation) foundation_synced_at: now, cache_valid_until: cacheValidUntil, // Never overwrite local timestamps updated_at: now, }); if (error) { console.error( "[Foundation Passport Sync] Failed to sync user profile:", error, ); throw new Error("Failed to cache Foundation passport data locally"); } console.log( "[Foundation Passport Sync] User synced successfully:", foundationUser.id, "| Cache valid until:", cacheValidUntil, ); } /** * 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); }