Foundation OAuth Client with PKCE Implementation

cgen-d3f55965924940e5bfdd21901778da89
This commit is contained in:
Builder.io 2025-11-17 02:24:18 +00:00
parent 3e51e9887c
commit f3fad472ca

View file

@ -1,99 +1,212 @@
/** /**
* Foundation OAuth Client * Foundation OAuth Client with PKCE
* *
* This module handles the OAuth flow with aethex.foundation as the identity provider. * Implements OAuth 2.0 with PKCE (Proof Key for Code Exchange) for secure
* aethex.dev acts as an OAuth client, redirecting users to Foundation for authentication. * 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 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"; 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?: { function generateCodeVerifier(): string {
redirectTo?: string; const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
state?: string; const length = 64;
}): string { let verifier = "";
const params = new URLSearchParams(); const randomValues = new Uint8Array(length);
crypto.getRandomValues(randomValues);
// client_id identifies aethex.dev as the client
params.set("client_id", "aethex-corp"); for (let i = 0; i < length; i++) {
verifier += charset[randomValues[i] % charset.length];
// 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);
} }
return verifier;
}
/**
* Create PKCE code challenge from code verifier
* Uses SHA256 hash with base64url encoding
*/
async function generateCodeChallenge(verifier: string): Promise<string> {
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<string> {
// 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()}`; 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 { export async function initiateFoundationLogin(redirectTo?: string): Promise<void> {
// Store intended destination for after auth try {
if (redirectTo) { const authUrl = await getFoundationAuthorizationUrl({ redirectTo });
sessionStorage.setItem("auth_redirect_to", 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 * Exchange authorization code for access token using PKCE
* This is called from the Foundation callback endpoint on the backend * Should be called from backend to keep client_secret secure
*/ */
export async function exchangeCodeForToken(code: string): Promise<{ export async function exchangeCodeForToken(code: string): Promise<{
accessToken: string; accessToken: string;
tokenType: string;
expiresIn: number;
user: any; 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", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ code }), body: JSON.stringify({ code }),
credentials: "include", // Include cookies for session credentials: "include",
}); });
if (!response.ok) { if (!response.ok) {
const error = await response.json().catch(() => ({})); const error = await response.json().catch(() => ({}));
throw new Error(error.message || "Failed to exchange code for token"); throw new Error(error.message || "Failed to exchange code for token");
} }
return response.json(); 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; 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 { export function getStoredState(): string | null {
if (typeof window === "undefined") return; if (typeof window === "undefined") return null;
sessionStorage.removeItem("auth_redirect_to"); 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;
} }