UNPKG

shogun-core

Version:

SHOGUN SDK - Core library for Shogun SDK

543 lines (542 loc) 19.8 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.OAuthConnector = void 0; /** * OAuth Connector - Simple version for GunDB user creation */ const ethers_1 = require("ethers"); const logger_1 = require("../../utils/logger"); const eventEmitter_1 = require("../../utils/eventEmitter"); /** * OAuth Connector */ class OAuthConnector extends eventEmitter_1.EventEmitter { DEFAULT_CONFIG = { providers: { google: { clientId: "", clientSecret: "", 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", }, github: { clientId: "", clientSecret: "", 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", }, discord: { clientId: "", clientSecret: "", 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", }, twitter: { clientId: "", clientSecret: "", 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", }, custom: { clientId: "", clientSecret: "", redirectUri: "", scope: [], authUrl: "", tokenUrl: "", userInfoUrl: "", }, }, usePKCE: true, cacheDuration: 24 * 60 * 60 * 1000, // 24 hours timeout: 60000, maxRetries: 3, retryDelay: 1000, }; 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 || {}), }, }; } /** * Update the connector configuration * @param config - New configuration options */ updateConfig(config) { this.config = { ...this.config, ...config, providers: { ...(this.config.providers || {}), ...(config.providers || {}), }, }; (0, logger_1.logDebug)("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 sessionStorage !== "undefined") { sessionStorage.setItem(key, value); } else { this.memoryStorage.set(key, value); } } getItem(key) { if (typeof sessionStorage !== "undefined") { return sessionStorage.getItem(key); } else { return this.memoryStorage.get(key) || null; } } removeItem(key) { if (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 with a provider */ async initiateOAuth(provider) { try { (0, logger_1.logDebug)(`Initiating OAuth flow with ${provider}`); const providerConfig = this.config.providers[provider]; if (!providerConfig || !providerConfig.clientId) { throw new Error(`Provider ${provider} not configured`); } // Generate state for CSRF protection const state = this.generateRandomString(32); this.setItem(`oauth_state_${provider}`, state); // Generate PKCE challenge if enabled let pkceParams = {}; if (this.config.usePKCE) { const { codeVerifier, codeChallenge } = await this.generatePKCEChallenge(); this.setItem(`oauth_verifier_${provider}`, codeVerifier); pkceParams = { code_challenge: codeChallenge, code_challenge_method: "S256", }; } // Build authorization URL const authParams = new URLSearchParams({ client_id: providerConfig.clientId, redirect_uri: providerConfig.redirectUri, scope: providerConfig.scope.join(" "), response_type: "code", state, ...pkceParams, }); // Handle the authorization URL that might already contain query parameters let authUrl = providerConfig.authUrl || ""; if (!authUrl) { throw new Error(`Auth URL not configured for provider ${provider}`); } // 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) { (0, logger_1.logError)(`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.`; (0, logger_1.logError)(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"; (0, logger_1.logError)(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) { (0, logger_1.logError)(`Error completing OAuth with ${provider}:`, error); return { success: false, error: error.message, }; } } /** * Generate credentials from OAuth user info */ async generateCredentials(userInfo, provider) { const providerConfig = this.config.providers?.[provider]; if (!providerConfig) { throw new Error(`Provider ${provider} is not configured.`); } const salt = `${provider}@${userInfo.id}`; const username = userInfo.email || `${userInfo.id}@${provider}.shogun`; try { (0, logger_1.logDebug)(`Generating credentials for ${provider} user: ${userInfo.id}`); // Generate deterministic password const password = await this.generateDeterministicPassword(userInfo, provider); const credentials = { username, password, provider, }; // Cache the user info this.cacheUserInfo(userInfo.id, provider, userInfo); (0, logger_1.logDebug)("OAuth credentials generated successfully"); return credentials; } catch (error) { (0, logger_1.logError)("Error generating OAuth credentials:", error); throw error; } } /** * Generate deterministic password */ async generateDeterministicPassword(userInfo, provider) { const passwordBase = `${userInfo.id}_${provider}_${userInfo.email || ""}_shogun_oauth`; const passwordHash = ethers_1.ethers.keccak256(ethers_1.ethers.toUtf8Bytes(passwordBase)); return passwordHash.slice(2, 34); // 32 character hex string } /** * Exchange authorization code for access token */ async exchangeCodeForToken(provider, providerConfig, code, state) { const storedState = await this.getItem(`oauth_state_${provider}`); if (state && storedState !== state) { throw new Error("Invalid state parameter"); } const tokenParams = { client_id: providerConfig.clientId, code: code, redirect_uri: providerConfig.redirectUri, grant_type: "authorization_code", }; // Add client secret if available if (providerConfig.clientSecret) { tokenParams.client_secret = providerConfig.clientSecret; } if (this.config.usePKCE) { const verifier = await this.getItem(`oauth_verifier_${provider}`); if (verifier) { tokenParams.code_verifier = verifier; this.removeItem(`oauth_verifier_${provider}`); } else { (0, logger_1.logWarn)(`PKCE is enabled, but no code verifier was found for ${provider}.`); } } const response = await fetch(providerConfig.tokenUrl, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json", }, body: new URLSearchParams(tokenParams), }); if (!response.ok) { const errorData = await response .json() .catch(() => ({ error: response.statusText })); throw new Error(`Token exchange failed: ${response.status} ${response.statusText} - ${JSON.stringify(errorData)}`); } return await response.json(); } /** * Get user info from OAuth provider */ async fetchUserInfo(provider, providerConfig, accessToken) { const response = await fetch(providerConfig.userInfoUrl, { headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/json", }, }); if (!response.ok) { throw new Error(`Failed to get user info: ${response.statusText}`); } const userData = await response.json(); return this.normalizeUserInfo(userData, provider); } /** * Normalize user info across different providers */ normalizeUserInfo(userData, provider) { switch (provider) { case "google": return { id: userData.id, email: userData.email, name: userData.name, picture: userData.picture, verified_email: userData.verified_email, provider, }; case "github": return { id: userData.id.toString(), email: userData.email, name: userData.name || userData.login, picture: userData.avatar_url, provider, }; default: return { id: userData.id?.toString() || userData.sub, email: userData.email, name: userData.name || userData.username, picture: userData.picture || userData.avatar_url, provider, }; } } /** * Cache user info */ cacheUserInfo(userId, provider, userInfo) { const cacheKey = `${provider}_${userId}`; const cacheEntry = { data: userInfo, timestamp: Date.now(), provider, userId, }; this.userCache.set(cacheKey, cacheEntry); // Also store in localStorage for persistence try { localStorage.setItem(`shogun_oauth_user_${cacheKey}`, JSON.stringify(cacheEntry)); } catch (error) { (0, logger_1.logError)("Error caching user info:", error); } } /** * Get cached user info */ getCachedUserInfo(userId, provider) { const cacheKey = `${provider}_${userId}`; // Check memory cache first const cached = this.userCache.get(cacheKey); if (cached && cached.data && Date.now() - cached.timestamp <= this.config.cacheDuration) { return cached.data; } // Check localStorage try { const localCached = localStorage.getItem(`shogun_oauth_user_${cacheKey}`); if (localCached) { const parsedCache = JSON.parse(localCached); if (parsedCache.data && Date.now() - parsedCache.timestamp <= this.config.cacheDuration) { this.userCache.set(cacheKey, parsedCache); return parsedCache.data; } else { localStorage.removeItem(`shogun_oauth_user_${cacheKey}`); } } } catch (error) { (0, logger_1.logError)("Error reading cached user info:", error); } return null; } /** * Clear user info cache */ clearUserCache(userId, provider) { if (userId && provider) { const cacheKey = `${provider}_${userId}`; this.userCache.delete(cacheKey); try { localStorage.removeItem(`shogun_oauth_user_${cacheKey}`); } catch (error) { (0, logger_1.logError)("Error clearing user cache:", error); } } else { // Clear all caches this.userCache.clear(); try { 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) { (0, logger_1.logError)("Error clearing all user caches:", error); } } } /** * Cleanup resources */ cleanup() { this.removeAllListeners(); this.userCache.clear(); } } exports.OAuthConnector = OAuthConnector;