aethex-forge/api/auth/callback.ts
Builder.io e732100390 Add explicit validation that passport is from Foundation
cgen-63108e08239b4627bd2f01699e795e69
2025-11-17 03:54:42 +00:00

359 lines
11 KiB
TypeScript

/**
* 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<FoundationUserInfo> {
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<void> {
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);
}