UNPKG

shogun-core

Version:

SHOGUN CORE - Core library for Shogun Ecosystem

761 lines (760 loc) 31.7 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.OAuthConnector = void 0; /** * OAuth Connector - Secure version for GunDB user creation */ const eventEmitter_1 = require("../../utils/eventEmitter"); const derive_1 = __importDefault(require("../../gundb/derive")); const validation_1 = require("../../utils/validation"); const ethers_1 = require("ethers"); /** * OAuth Connector */ class OAuthConnector extends eventEmitter_1.EventEmitter { DEFAULT_CONFIG = { providers: { google: { clientId: "", redirectUri: `${this.getOrigin()}/auth/callback`, scope: ["openid", "email", "profile"], authUrl: "https://accounts.google.com/o/oauth2/v2/auth", tokenUrl: "https://oauth2.googleapis.com/token", userInfoUrl: "https://www.googleapis.com/oauth2/v2/userinfo", usePKCE: true, // Forza PKCE per Google }, github: { clientId: "", redirectUri: `${this.getOrigin()}/auth/callback`, scope: ["user:email"], authUrl: "https://github.com/login/oauth/authorize", tokenUrl: "https://github.com/login/oauth/access_token", userInfoUrl: "https://api.github.com/user", usePKCE: true, }, discord: { clientId: "", redirectUri: `${this.getOrigin()}/auth/callback`, scope: ["identify", "email"], authUrl: "https://discord.com/api/oauth2/authorize", tokenUrl: "https://discord.com/api/oauth2/token", userInfoUrl: "https://discord.com/api/users/@me", usePKCE: true, }, twitter: { clientId: "", redirectUri: `${this.getOrigin()}/auth/callback`, scope: ["tweet.read", "users.read"], authUrl: "https://twitter.com/i/oauth2/authorize", tokenUrl: "https://api.twitter.com/2/oauth2/token", userInfoUrl: "https://api.twitter.com/2/users/me", usePKCE: true, }, custom: { clientId: "", redirectUri: "", scope: [], authUrl: "", tokenUrl: "", userInfoUrl: "", usePKCE: true, }, }, usePKCE: true, // PKCE abilitato di default per sicurezza cacheDuration: 24 * 60 * 60 * 1000, // 24 hours timeout: 60000, maxRetries: 3, retryDelay: 1000, allowUnsafeClientSecret: false, // Disabilitato per sicurezza stateTimeout: 10 * 60 * 1000, // 10 minuti per il timeout dello state }; config; userCache = new Map(); // Fallback storage for Node.js environment memoryStorage = new Map(); constructor(config = {}) { super(); this.config = { ...this.DEFAULT_CONFIG, ...config, providers: { ...(this.DEFAULT_CONFIG.providers || {}), ...(config.providers || {}), }, }; // Validazione di sicurezza post-costruzione this.validateSecurityConfig(); } /** * Validates security configuration */ validateSecurityConfig() { const providers = this.config.providers || {}; for (const [providerName, providerConfig] of Object.entries(providers)) { if (!providerConfig) continue; // Verify that PKCE is enabled for all providers in browser if (typeof window !== "undefined" && !providerConfig.usePKCE) { console.warn(`Provider ${providerName} does not have PKCE enabled - not secure for browser`); // Force PKCE for all providers in browser, except if already configured differently providerConfig.usePKCE = true; } // Verify that there is no client_secret in browser (except Google with PKCE) if (typeof window !== "undefined" && providerConfig.clientSecret) { if (providerName === "google" && providerConfig.usePKCE) { console.log(`Provider ${providerName} has client_secret configured - OK for Google with PKCE`); } else { console.error(`Provider ${providerName} has client_secret configured in browser - REMOVE IMMEDIATELY`); // Remove client_secret for security in browser delete providerConfig.clientSecret; console.log(`Provider ${providerName} client_secret removed for security in browser`); } } } } /** * Update the connector configuration * @param config - New configuration options */ updateConfig(config) { this.config = { ...this.config, ...config, providers: { ...(this.config.providers || {}), ...(config.providers || {}), }, }; console.log("OAuthConnector configuration updated", this.config); } /** * Get origin URL (browser or Node.js compatible) */ getOrigin() { if (typeof window !== "undefined" && window.location) { return window.location.origin; } // Fallback for Node.js environment return "http://localhost:3000"; } /** * Storage abstraction (browser sessionStorage or Node.js Map) */ setItem(key, value) { if (typeof window !== "undefined" && typeof sessionStorage !== "undefined") { sessionStorage.setItem(key, value); } else { this.memoryStorage.set(key, value); } } getItem(key) { if (typeof window !== "undefined" && typeof sessionStorage !== "undefined") { return sessionStorage.getItem(key); } else { return this.memoryStorage.get(key) || null; } } removeItem(key) { if (typeof window !== "undefined" && typeof sessionStorage !== "undefined") { sessionStorage.removeItem(key); } else { this.memoryStorage.delete(key); } } /** * Check if OAuth is supported */ isSupported() { // In Node.js, we can still demonstrate the functionality return typeof URLSearchParams !== "undefined"; } /** * Get available OAuth providers */ getAvailableProviders() { return Object.keys(this.config.providers || {}).filter((provider) => this.config.providers[provider]?.clientId); } /** * Generate PKCE challenge for secure OAuth flow */ async generatePKCEChallenge() { const codeVerifier = this.generateRandomString(128); const codeChallenge = await this.calculatePKCECodeChallenge(codeVerifier); return { codeVerifier, codeChallenge }; } /** * Calculate the PKCE code challenge from a code verifier. * Hashes the verifier using SHA-256 and then base64url encodes it. * @param verifier The code verifier string. * @returns The base64url-encoded SHA-256 hash of the verifier. */ async calculatePKCECodeChallenge(verifier) { if (typeof window !== "undefined" && window.crypto && window.crypto.subtle) { // Browser environment const encoder = new TextEncoder(); const data = encoder.encode(verifier); const hashBuffer = await window.crypto.subtle.digest("SHA-256", data); return this.base64urlEncode(hashBuffer); } else { // Node.js environment const crypto = require("crypto"); const hash = crypto.createHash("sha256").update(verifier).digest(); return this.base64urlEncode(hash); } } /** * Encodes a buffer into a Base64URL-encoded string. * @param buffer The buffer to encode. * @returns The Base64URL-encoded string. */ base64urlEncode(buffer) { let base64string; // In Node.js, we can use the Buffer object. In the browser, we need a different approach. if (typeof Buffer !== "undefined" && Buffer.isBuffer(buffer)) { // Node.js path base64string = buffer.toString("base64"); } else { // Browser path (assuming ArrayBuffer) const bytes = new Uint8Array(buffer); let binary = ""; for (let i = 0; i < bytes.length; i++) { binary += String.fromCharCode(bytes[i]); } base64string = window.btoa(binary); } return base64string .replace(/\+/g, "-") .replace(/\//g, "_") .replace(/=/g, ""); } /** * Generate cryptographically secure random string */ generateRandomString(length) { const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"; let randomValues; if (typeof window !== "undefined" && window.crypto) { // Browser environment randomValues = new Uint8Array(length); window.crypto.getRandomValues(randomValues); } else { // Node.js environment const crypto = require("crypto"); randomValues = new Uint8Array(crypto.randomBytes(length)); } return Array.from(randomValues) .map((value) => charset[value % charset.length]) .join(""); } /** * Initiate OAuth flow */ async initiateOAuth(provider) { const providerConfig = this.config.providers?.[provider]; if (!providerConfig) { const errorMsg = `Provider '${provider}' is not configured.`; console.error(errorMsg); return { success: false, error: errorMsg }; } // Validazione di sicurezza pre-inizializzazione if (typeof window !== "undefined" && providerConfig.clientSecret) { // Google OAuth richiede client_secret anche con PKCE if (provider === "google" && providerConfig.usePKCE) { console.log(`Provider ${provider} has client_secret configured - OK for Google with PKCE`); } else { const errorMsg = `Client secret cannot be used in browser for ${provider}`; console.error(errorMsg); return { success: false, error: errorMsg }; } } try { const state = this.generateRandomString(32); const stateTimestamp = Date.now(); // Salva state con timestamp per validazione timeout this.setItem(`oauth_state_${provider}`, state); this.setItem(`oauth_state_timestamp_${provider}`, stateTimestamp.toString()); let authUrl = providerConfig.authUrl; const authParams = new URLSearchParams({ client_id: providerConfig.clientId, redirect_uri: providerConfig.redirectUri, response_type: "code", state, }); // Add scope if configured if (providerConfig.scope && providerConfig.scope.length > 0) { authParams.set("scope", providerConfig.scope.join(" ")); } // Add Google-specific parameters for better UX if (provider === "google") { authParams.set("prompt", "select_account"); // Force account selection authParams.set("access_type", "offline"); // Get refresh token authParams.set("include_granted_scopes", "true"); // Include previously granted scopes } // PKCE è obbligatorio per sicurezza const isPKCEEnabled = providerConfig.usePKCE ?? this.config.usePKCE ?? true; if (!isPKCEEnabled && typeof window !== "undefined") { const errorMsg = `PKCE is required for ${provider} in browser for security reasons`; console.error(errorMsg); return { success: false, error: errorMsg }; } if (isPKCEEnabled) { console.log("PKCE is enabled, generating challenge..."); const { codeVerifier, codeChallenge } = await this.generatePKCEChallenge(); console.log(`Generated code verifier: ${codeVerifier.substring(0, 10)}... (length: ${codeVerifier.length})`); console.log(`Generated code challenge: ${codeChallenge.substring(0, 10)}... (length: ${codeChallenge.length})`); this.setItem(`oauth_verifier_${provider}`, codeVerifier); this.setItem(`oauth_verifier_timestamp_${provider}`, stateTimestamp.toString()); console.log(`Saved code verifier to storage with key: oauth_verifier_${provider}`); authParams.set("code_challenge", codeChallenge); authParams.set("code_challenge_method", "S256"); console.log("Added PKCE parameters to auth URL"); } // If the authorization URL already contains query parameters, add the new parameters if (authUrl.includes("?")) { authUrl = `${authUrl}&${authParams.toString()}`; } else { authUrl = `${authUrl}?${authParams.toString()}`; } this.emit("oauth_initiated", { provider, authUrl }); return { success: true, provider, authUrl, }; } catch (error) { console.error(`Error initiating OAuth with ${provider}:`, error); return { success: false, error: error.message, }; } } /** * Complete OAuth flow */ async completeOAuth(provider, authCode, state) { const providerConfig = this.config.providers?.[provider]; if (!providerConfig) { const errorMsg = `Provider '${provider}' is not configured.`; console.error(errorMsg); return { success: false, error: errorMsg }; } try { const tokenData = await this.exchangeCodeForToken(provider, providerConfig, authCode, state); if (!tokenData.access_token) { const errorMsg = "No access token received from provider"; console.error(errorMsg, tokenData); return { success: false, error: errorMsg }; } const userInfo = await this.fetchUserInfo(provider, providerConfig, tokenData.access_token); // Cache user info this.cacheUserInfo(userInfo.id, provider, userInfo); // Generate credentials const credentials = await this.generateCredentials(userInfo, provider); this.emit("oauth_completed", { provider, userInfo, credentials }); return { success: true, provider, userInfo, }; } catch (error) { console.error(`Error completing OAuth with ${provider}:`, error); return { success: false, error: error.message, }; } } /** * Generate credentials from OAuth user info * Ora restituisce anche la chiave GunDB derivata (key) */ async generateCredentials(userInfo, provider) { const providerConfig = this.config.providers?.[provider]; if (!providerConfig) { throw new Error(`Provider ${provider} is not configured.`); } // Username uniforme const username = (0, validation_1.generateUsernameFromIdentity)(provider, userInfo); try { console.log(`Generating credentials for ${provider} user: ${userInfo.id}`); const saltData = `${userInfo.id}_${provider}_${userInfo.email || "no-email"}`; const salt = ethers_1.ethers.keccak256(ethers_1.ethers.toUtf8Bytes(saltData)); // Password deterministica (compatibilità) const password = (0, validation_1.generateDeterministicPassword)(salt); // Deriva la chiave GunDB const key = await (0, derive_1.default)(password, salt, { includeP256: true }); const credentials = { username, password, provider, key, }; this.cacheUserInfo(userInfo.id, provider, userInfo); console.log("OAuth credentials generated successfully"); return credentials; } catch (error) { console.error("Error generating OAuth credentials:", error); throw error; } } /** * Exchange authorization code for access token */ async exchangeCodeForToken(provider, providerConfig, code, state) { const storedState = this.getItem(`oauth_state_${provider}`); const storedStateTimestamp = this.getItem(`oauth_state_timestamp_${provider}`); if (!state || !storedState || state !== storedState) { this.removeItem(`oauth_state_${provider}`); this.removeItem(`oauth_state_timestamp_${provider}`); throw new Error("Invalid state parameter or expired"); } // Validazione del timestamp dello state if (storedStateTimestamp) { const stateTimestamp = parseInt(storedStateTimestamp, 10); const stateTimeout = this.config.stateTimeout || 10 * 60 * 1000; // Default 10 minuti if (Date.now() - stateTimestamp > stateTimeout) { this.removeItem(`oauth_state_${provider}`); this.removeItem(`oauth_state_timestamp_${provider}`); throw new Error("State parameter expired"); } } this.removeItem(`oauth_state_${provider}`); this.removeItem(`oauth_state_timestamp_${provider}`); const tokenParams = { client_id: providerConfig.clientId, code: code, redirect_uri: providerConfig.redirectUri, grant_type: "authorization_code", }; // Check for PKCE first const isPKCEEnabled = providerConfig.usePKCE ?? this.config.usePKCE; if (isPKCEEnabled) { console.log("PKCE enabled, retrieving code verifier..."); // Debug: Show all oauth-related keys in sessionStorage if (typeof sessionStorage !== "undefined") { const oauthKeys = []; for (let i = 0; i < sessionStorage.length; i++) { const key = sessionStorage.key(i); if (key && key.startsWith("oauth_")) { oauthKeys.push(key); } } console.log("OAuth keys in sessionStorage:", oauthKeys); } const verifier = this.getItem(`oauth_verifier_${provider}`); const verifierTimestamp = this.getItem(`oauth_verifier_timestamp_${provider}`); console.log(`Looking for key: oauth_verifier_${provider}, found:`, !!verifier); if (verifier && verifierTimestamp) { const verifierTimestampInt = parseInt(verifierTimestamp, 10); const stateTimeout = this.config.stateTimeout || 10 * 60 * 1000; // Default 10 minuti if (Date.now() - verifierTimestampInt > stateTimeout) { console.warn(`Code verifier expired for PKCE flow for ${provider}`); this.removeItem(`oauth_verifier_${provider}`); this.removeItem(`oauth_verifier_timestamp_${provider}`); throw new Error("Code verifier expired"); } console.log(`Found code verifier for PKCE flow: ${verifier.substring(0, 10)}... (length: ${verifier.length})`); tokenParams.code_verifier = verifier; } else { // Fallback: prova a generare un nuovo verifier (non ideale ma funziona per test) console.warn("PKCE enabled but no code verifier found. Attempting fallback..."); try { const { codeVerifier } = await this.generatePKCEChallenge(); tokenParams.code_verifier = codeVerifier; console.log("Generated fallback code verifier"); } catch (fallbackError) { throw new Error("PKCE enabled but no code verifier found and fallback failed"); } } } else { // PKCE non abilitato - non sicuro per browser if (typeof window !== "undefined") { throw new Error("PKCE is required for browser applications. Client secret cannot be used in browser."); } // Solo per ambiente Node.js con client_secret if (providerConfig.clientSecret && providerConfig.clientSecret.trim() !== "") { tokenParams.client_secret = providerConfig.clientSecret; console.log("Using client_secret for server-side OAuth flow"); } else { throw new Error("Client secret is required when PKCE is not enabled for server-side flows."); } } // Google OAuth richiede client_secret anche con PKCE // Questo è un comportamento specifico di Google, non una vulnerabilità if (provider === "google" && providerConfig.clientSecret && providerConfig.clientSecret.trim() !== "") { tokenParams.client_secret = providerConfig.clientSecret; console.log("Adding client_secret for Google OAuth (required even with PKCE)"); } // Clean up verifier this.removeItem(`oauth_verifier_${provider}`); this.removeItem(`oauth_verifier_timestamp_${provider}`); const urlParams = new URLSearchParams(tokenParams); console.log("Request body keys:", Array.from(urlParams.keys())); const response = await fetch(providerConfig.tokenUrl, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", }, body: urlParams.toString(), }); if (!response.ok) { const errorData = await response.json().catch(() => ({})); throw new Error(`Token exchange failed: ${response.status} ${response.statusText} - ${JSON.stringify(errorData)}`); } return await response.json(); } /** * Fetch user info from provider */ async fetchUserInfo(provider, providerConfig, accessToken) { const response = await fetch(providerConfig.userInfoUrl, { headers: { Authorization: `Bearer ${accessToken}`, }, }); if (!response.ok) { throw new Error(`Failed to fetch user info: ${response.status} ${response.statusText}`); } const userData = await response.json(); return this.normalizeUserInfo(userData, provider); } /** * Normalize user info from different providers */ normalizeUserInfo(userData, provider) { switch (provider) { case "google": return { id: userData.id, email: userData.email, name: userData.name, picture: userData.picture, provider, }; case "github": return { id: userData.id.toString(), email: userData.email, name: userData.name || userData.login, picture: userData.avatar_url, provider, }; case "discord": return { id: userData.id, email: userData.email, name: userData.username, picture: `https://cdn.discordapp.com/avatars/${userData.id}/${userData.avatar}.png`, provider, }; case "twitter": return { id: userData.data.id, email: userData.data.email, name: userData.data.name, picture: userData.data.profile_image_url, provider, }; default: return { id: userData.id?.toString() || "", email: userData.email || "", name: userData.name || "", picture: userData.picture || userData.avatar_url || "", provider, }; } } /** * Cache user info */ cacheUserInfo(userId, provider, userInfo) { const cacheKey = `${provider}_${userId}`; const cacheEntry = { data: userInfo, provider, userId, timestamp: Date.now(), }; this.userCache.set(cacheKey, cacheEntry); // Salva solo dati minimi in localStorage (solo se disponibile) try { if (typeof window !== "undefined" && typeof localStorage !== "undefined") { const minimalCacheEntry = { userId: userInfo.id, provider, timestamp: Date.now(), }; localStorage.setItem(`shogun_oauth_user_${cacheKey}`, JSON.stringify(minimalCacheEntry)); } } catch (error) { console.warn("Failed to persist user info in localStorage:", error); } } /** * Get cached user info */ getCachedUserInfo(userId, provider) { const cacheKey = `${provider}_${userId}`; // First check memory cache const cached = this.userCache.get(cacheKey); if (cached) { // Check if cache is still valid if (this.config.cacheDuration && Date.now() - cached.timestamp <= this.config.cacheDuration) { return cached.data || null; } } // Then check localStorage (solo se disponibile) try { if (typeof window !== "undefined" && typeof localStorage !== "undefined") { const localCached = localStorage.getItem(`shogun_oauth_user_${cacheKey}`); if (localCached) { const parsedCache = JSON.parse(localCached); if (this.config.cacheDuration && Date.now() - parsedCache.timestamp <= this.config.cacheDuration) { // Update memory cache this.userCache.set(cacheKey, parsedCache); return parsedCache.userInfo; } } } } catch (error) { console.warn("Failed to read user info from localStorage:", error); } return null; } /** * Clear user cache */ clearUserCache(userId, provider) { if (userId && provider) { const cacheKey = `${provider}_${userId}`; this.userCache.delete(cacheKey); try { if (typeof window !== "undefined" && typeof localStorage !== "undefined") { localStorage.removeItem(`shogun_oauth_user_${cacheKey}`); } } catch (error) { console.warn("Failed to remove user info from localStorage:", error); } } else { // Clear all cache this.userCache.clear(); try { if (typeof window !== "undefined" && typeof localStorage !== "undefined") { const keysToRemove = []; for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i); if (key && key.startsWith("shogun_oauth_user_")) { keysToRemove.push(key); } } keysToRemove.forEach((key) => localStorage.removeItem(key)); } } catch (error) { console.warn("Failed to clear user info from localStorage:", error); } } } /** * Cleanup */ cleanup() { this.removeAllListeners(); this.userCache.clear(); this.cleanupExpiredOAuthData(); } /** * Clean up expired OAuth data from storage */ cleanupExpiredOAuthData() { const stateTimeout = this.config.stateTimeout || 10 * 60 * 1000; const currentTime = Date.now(); // Clean sessionStorage if (typeof sessionStorage !== "undefined") { const keysToRemove = []; for (let i = 0; i < sessionStorage.length; i++) { const key = sessionStorage.key(i); if (key && key.startsWith("oauth_state_timestamp_")) { const timestamp = sessionStorage.getItem(key); if (timestamp) { const stateTime = parseInt(timestamp, 10); if (currentTime - stateTime > stateTimeout) { const stateKey = key.replace("_timestamp", ""); keysToRemove.push(key, stateKey); } } } if (key && key.startsWith("oauth_verifier_timestamp_")) { const timestamp = sessionStorage.getItem(key); if (timestamp) { const verifierTime = parseInt(timestamp, 10); if (currentTime - verifierTime > stateTimeout) { const verifierKey = key.replace("_timestamp", ""); keysToRemove.push(key, verifierKey); } } } } keysToRemove.forEach((key) => sessionStorage.removeItem(key)); if (keysToRemove.length > 0) { console.log(`Cleaned up ${keysToRemove.length} expired OAuth entries`); } } // Clean memoryStorage (Node.js) const memoryKeysToRemove = []; for (const [key, value] of this.memoryStorage.entries()) { if (key.startsWith("oauth_state_timestamp_") || key.startsWith("oauth_verifier_timestamp_")) { const timestamp = parseInt(value, 10); if (currentTime - timestamp > stateTimeout) { const baseKey = key.replace("_timestamp", ""); memoryKeysToRemove.push(key, baseKey); } } } memoryKeysToRemove.forEach((key) => this.memoryStorage.delete(key)); if (memoryKeysToRemove.length > 0) { console.log(`Cleaned up ${memoryKeysToRemove.length} expired OAuth entries from memory`); } } } exports.OAuthConnector = OAuthConnector;