From f3fad472cafc3dd847d0ff831c2568feacaa063e Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Mon, 17 Nov 2025 02:24:18 +0000 Subject: [PATCH] Foundation OAuth Client with PKCE Implementation cgen-d3f55965924940e5bfdd21901778da89 --- client/lib/foundation-oauth.ts | 223 +++++++++++++++++++++++++-------- 1 file changed, 168 insertions(+), 55 deletions(-) diff --git a/client/lib/foundation-oauth.ts b/client/lib/foundation-oauth.ts index 94443e77..516498b3 100644 --- a/client/lib/foundation-oauth.ts +++ b/client/lib/foundation-oauth.ts @@ -1,99 +1,212 @@ /** - * Foundation OAuth Client - * - * This module handles the OAuth flow with aethex.foundation as the identity provider. - * aethex.dev acts as an OAuth client, redirecting users to Foundation for authentication. + * Foundation OAuth Client with PKCE + * + * Implements OAuth 2.0 with PKCE (Proof Key for Code Exchange) for secure + * authentication with aethex.foundation as the identity provider. + * + * Foundation Endpoints: + * - GET /api/oauth/authorize - Authorization endpoint + * - POST /api/oauth/token - Token endpoint + * - GET /api/oauth/userinfo - User info endpoint */ const FOUNDATION_URL = import.meta.env.VITE_FOUNDATION_URL || "https://aethex.foundation"; +const CLIENT_ID = import.meta.env.VITE_FOUNDATION_OAUTH_CLIENT_ID || "aethex_corp"; const API_BASE = import.meta.env.VITE_API_BASE || "https://aethex.dev"; /** - * Generate authorization URL for redirecting to Foundation login + * Generate a random string for PKCE code verifier + * Must be 43-128 characters, URL-safe (A-Z, a-z, 0-9, -, ., _, ~) */ -export function getFoundationAuthorizationUrl(options?: { - redirectTo?: string; - state?: string; -}): string { - const params = new URLSearchParams(); - - // client_id identifies aethex.dev as the client - params.set("client_id", "aethex-corp"); - - // Redirect back to aethex.dev after authentication - params.set("redirect_uri", `${API_BASE}/api/auth/foundation-callback`); - - // OAuth standard - maintain context across redirect - params.set("response_type", "code"); - params.set("scope", "openid profile email"); - - // Custom state for additional context - if (options?.state) { - params.set("state", options.state); +function generateCodeVerifier(): string { + const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"; + const length = 64; + let verifier = ""; + const randomValues = new Uint8Array(length); + crypto.getRandomValues(randomValues); + + for (let i = 0; i < length; i++) { + verifier += charset[randomValues[i] % charset.length]; } - + + return verifier; +} + +/** + * Create PKCE code challenge from code verifier + * Uses SHA256 hash with base64url encoding + */ +async function generateCodeChallenge(verifier: string): Promise { + const encoder = new TextEncoder(); + const data = encoder.encode(verifier); + const digest = await crypto.subtle.digest("SHA-256", data); + + // Convert ArrayBuffer to base64url string + return btoa(String.fromCharCode(...new Uint8Array(digest))) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/g, ""); +} + +/** + * Generate PKCE parameters + */ +async function generatePKCEParams(): Promise<{ + verifier: string; + challenge: string; +}> { + const verifier = generateCodeVerifier(); + const challenge = await generateCodeChallenge(verifier); + return { verifier, challenge }; +} + +/** + * Generate a CSRF token (state parameter) + */ +function generateStateToken(): string { + const randomValues = new Uint8Array(32); + crypto.getRandomValues(randomValues); + return Array.from(randomValues) + .map((x) => x.toString(16).padStart(2, "0")) + .join(""); +} + +/** + * Build Foundation authorization URL + */ +export async function getFoundationAuthorizationUrl(options?: { + redirectTo?: string; +}): Promise { + // Generate PKCE parameters + const pkce = await generatePKCEParams(); + const state = generateStateToken(); + + // Store PKCE verifier and state in sessionStorage for callback + sessionStorage.setItem("oauth_code_verifier", pkce.verifier); + sessionStorage.setItem("oauth_state", state); + if (options?.redirectTo) { + sessionStorage.setItem("oauth_redirect_to", options.redirectTo); + } + + // Build authorization URL + const params = new URLSearchParams(); + params.set("client_id", CLIENT_ID); + params.set("redirect_uri", `${API_BASE}/auth/callback`); + params.set("response_type", "code"); + params.set("scope", "openid profile email achievements projects"); + params.set("state", state); + params.set("code_challenge", pkce.challenge); + params.set("code_challenge_method", "S256"); + return `${FOUNDATION_URL}/api/oauth/authorize?${params.toString()}`; } /** - * Start the OAuth flow by redirecting to Foundation + * Initiate the Foundation OAuth login flow */ -export function initiateFoundationLogin(redirectTo?: string): void { - // Store intended destination for after auth - if (redirectTo) { - sessionStorage.setItem("auth_redirect_to", redirectTo); +export async function initiateFoundationLogin(redirectTo?: string): Promise { + try { + const authUrl = await getFoundationAuthorizationUrl({ redirectTo }); + window.location.href = authUrl; + } catch (error) { + console.error("[Foundation OAuth] Failed to generate auth URL:", error); + throw new Error("Failed to initiate Foundation login"); } - - const state = JSON.stringify({ - redirectTo: redirectTo || "/dashboard", - timestamp: Date.now(), - }); - - const authUrl = getFoundationAuthorizationUrl({ - redirectTo, - state: encodeURIComponent(state), - }); - - window.location.href = authUrl; } /** - * Exchange authorization code for token - * This is called from the Foundation callback endpoint on the backend + * Exchange authorization code for access token using PKCE + * Should be called from backend to keep client_secret secure */ export async function exchangeCodeForToken(code: string): Promise<{ accessToken: string; + tokenType: string; + expiresIn: number; user: any; }> { - const response = await fetch(`${API_BASE}/api/auth/exchange-token`, { + const API_BASE = import.meta.env.VITE_API_BASE || "https://aethex.dev"; + + const response = await fetch(`${API_BASE}/auth/callback/exchange`, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ code }), - credentials: "include", // Include cookies for session + credentials: "include", }); - + if (!response.ok) { const error = await response.json().catch(() => ({})); throw new Error(error.message || "Failed to exchange code for token"); } - + return response.json(); } /** - * Get the stored redirect destination after auth + * Get stored PKCE verifier from session */ -export function getStoredRedirectTo(): string | null { +export function getStoredCodeVerifier(): string | null { if (typeof window === "undefined") return null; - return sessionStorage.getItem("auth_redirect_to"); + return sessionStorage.getItem("oauth_code_verifier"); } /** - * Clear the stored redirect destination + * Get stored state token from session */ -export function clearStoredRedirectTo(): void { - if (typeof window === "undefined") return; - sessionStorage.removeItem("auth_redirect_to"); +export function getStoredState(): string | null { + if (typeof window === "undefined") return null; + return sessionStorage.getItem("oauth_state"); +} + +/** + * Get stored redirect destination + */ +export function getStoredRedirectTo(): string | null { + if (typeof window === "undefined") return null; + return sessionStorage.getItem("oauth_redirect_to"); +} + +/** + * Clear stored OAuth parameters + */ +export function clearOAuthStorage(): void { + if (typeof window === "undefined") return; + sessionStorage.removeItem("oauth_code_verifier"); + sessionStorage.removeItem("oauth_state"); + sessionStorage.removeItem("oauth_redirect_to"); +} + +/** + * Get authorization code from URL + */ +export function getAuthorizationCode(): string | null { + if (typeof window === "undefined") return null; + const params = new URLSearchParams(window.location.search); + return params.get("code"); +} + +/** + * Get state from URL (for CSRF validation) + */ +export function getStateFromUrl(): string | null { + if (typeof window === "undefined") return null; + const params = new URLSearchParams(window.location.search); + return params.get("state"); +} + +/** + * Check if we have an authorization code in the URL + */ +export function hasAuthorizationCode(): boolean { + return !!getAuthorizationCode(); +} + +/** + * Validate state token (CSRF protection) + */ +export function validateState(urlState: string | null): boolean { + if (!urlState) return false; + const storedState = getStoredState(); + return urlState === storedState; }