diff --git a/api/auth/callback.ts b/api/auth/callback.ts new file mode 100644 index 00000000..aed21490 --- /dev/null +++ b/api/auth/callback.ts @@ -0,0 +1,273 @@ +/** + * 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); +}