shogun-core
Version:
SHOGUN SDK - Core library for Shogun SDK
543 lines (542 loc) • 19.8 kB
JavaScript
;
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;