UNPKG

@wauth/sdk

Version:
1,022 lines 57.3 kB
import PocketBase, {} from "pocketbase"; import Arweave from "arweave"; import Transaction from "arweave/web/lib/transaction"; import {} from "arconnect"; import { DataItem } from "@dha-team/arbundles"; import axios from "axios"; import base64url from "base64url"; import { WAUTH_VERSION } from "./version"; import { createModal, createModalContainer, HTMLSanitizer } from "./modal-helper"; import { dryrun } from "@permaweb/aoconnect"; import { wauthLogger, loggedWAuthOperation } from "./logger"; export var WAuthProviders; (function (WAuthProviders) { WAuthProviders["Google"] = "google"; WAuthProviders["Github"] = "github"; WAuthProviders["Discord"] = "discord"; WAuthProviders["X"] = "twitter"; })(WAuthProviders || (WAuthProviders = {})); export var WalletActions; (function (WalletActions) { WalletActions["SIGN"] = "sign"; WalletActions["ENCRYPT"] = "encrypt"; WalletActions["DECRYPT"] = "decrypt"; WalletActions["DISPATCH"] = "dispatch"; WalletActions["SIGN_DATA_ITEM"] = "signDataItem"; WalletActions["SIGNATURE"] = "signature"; })(WalletActions || (WalletActions = {})); export class WAuth { static devUrl = "http://localhost:8090"; static devBackendUrl = "http://localhost:8091"; static prodUrl = "https://wauth.arweave.tech"; static prodBackendUrl = "https://wauth-backend.arweave.tech"; pb; authData; wallet; authRecord; backendUrl; static version = WAUTH_VERSION; version = WAuth.version; authDataListeners = []; sessionPassword = null; // Store decrypted password in memory only sessionKey = null; // Key for local session encryption sessionPasswordLoading = false; // Prevent multiple simultaneous loading attempts modalInProgress = false; // Prevent multiple modals from showing simultaneously async initializeSessionKey() { if (this.sessionKey) return this.sessionKey; // Try to load existing session key const storedKey = localStorage.getItem('wauth_session_key'); if (storedKey) { try { const keyData = JSON.parse(storedKey); // Check if the key has expired (3 hours) if (keyData.timestamp && Date.now() - keyData.timestamp > 3 * 60 * 60 * 1000) { // Key has expired, remove it localStorage.removeItem('wauth_session_key'); localStorage.removeItem('wauth_encrypted_password'); throw new Error('Session key expired'); } this.sessionKey = await crypto.subtle.importKey('jwk', keyData.key, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']); return this.sessionKey; } catch (error) { // If key loading fails or is expired, generate a new one localStorage.removeItem('wauth_session_key'); localStorage.removeItem('wauth_encrypted_password'); } } // Generate new session key this.sessionKey = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']); // Store the key with timestamp const exportedKey = await crypto.subtle.exportKey('jwk', this.sessionKey); const keyData = { key: exportedKey, timestamp: Date.now() }; localStorage.setItem('wauth_session_key', JSON.stringify(keyData)); return this.sessionKey; } async storePasswordInSession(password) { if (typeof window === 'undefined' || !password) return; try { const sessionKey = await this.initializeSessionKey(); const encoder = new TextEncoder(); const data = encoder.encode(password); // Generate a random IV for each encryption const iv = crypto.getRandomValues(new Uint8Array(12)); const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, sessionKey, data); // Store encrypted password with IV and timestamp const encryptedData = { encrypted: Array.from(new Uint8Array(encrypted)), iv: Array.from(iv), timestamp: Date.now() }; localStorage.setItem('wauth_encrypted_password', JSON.stringify(encryptedData)); } catch (error) { wauthLogger.simple('error', 'Failed to store password in session', error); } } hasSessionStorageData() { if (typeof window === 'undefined') return false; const hasEncryptedPassword = localStorage.getItem('wauth_encrypted_password') !== null; const hasSessionKey = localStorage.getItem('wauth_session_key') !== null; // Check if data exists and is not expired if (hasEncryptedPassword && hasSessionKey) { try { const keyData = JSON.parse(localStorage.getItem('wauth_session_key')); const passwordData = JSON.parse(localStorage.getItem('wauth_encrypted_password')); const keyExpired = keyData.timestamp && Date.now() - keyData.timestamp > 3 * 60 * 60 * 1000; const passwordExpired = passwordData.timestamp && Date.now() - passwordData.timestamp > 3 * 60 * 60 * 1000; if (keyExpired || passwordExpired) { // Clear expired data localStorage.removeItem('wauth_session_key'); localStorage.removeItem('wauth_encrypted_password'); return false; } return true; } catch (error) { // If there's any error parsing the data, consider it invalid localStorage.removeItem('wauth_session_key'); localStorage.removeItem('wauth_encrypted_password'); return false; } } return false; } async loadPasswordFromSession() { if (typeof window === 'undefined') return null; try { const storedData = localStorage.getItem('wauth_encrypted_password'); if (!storedData) return null; const { encrypted, iv, timestamp } = JSON.parse(storedData); // Check if password data has expired (3 hours) if (timestamp && Date.now() - timestamp > 3 * 60 * 60 * 1000) { wauthLogger.simple('info', 'Stored password has expired'); localStorage.removeItem('wauth_encrypted_password'); localStorage.removeItem('wauth_session_key'); return null; } const sessionKey = await this.initializeSessionKey(); // If initializeSessionKey didn't throw (which it would if expired), proceed with decryption const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: new Uint8Array(iv) }, sessionKey, new Uint8Array(encrypted)); const decoder = new TextDecoder(); return decoder.decode(decrypted); } catch (error) { wauthLogger.simple('error', 'Failed to load password from session', error); // Clear invalid data localStorage.removeItem('wauth_encrypted_password'); localStorage.removeItem('wauth_session_key'); return null; } } clearSessionPassword() { if (typeof window !== 'undefined') { localStorage.removeItem('wauth_encrypted_password'); localStorage.removeItem('wauth_session_key'); } this.sessionPassword = null; this.sessionKey = null; } clearAllAuthData(clearLocalStorage = true) { // Clear session password and storage this.clearSessionPassword(); // Clear PocketBase auth data this.pb.authStore.clear(); // Clear additional localStorage items if any if (typeof window !== 'undefined' && clearLocalStorage) { // Clear any PocketBase auth data from localStorage // localStorage.removeItem('pocketbase_auth'); // Clear any other wauth-related items Object.keys(localStorage).forEach(key => { if (key.startsWith('wauth_')) { localStorage.removeItem(key); } }); } // Reset instance variables this.authData = null; this.wallet = null; this.authRecord = null; wauthLogger.simple('info', 'Cleared all authentication data'); } // Method to check if backend is accessible async isBackendAccessible() { try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 5000); // 5 second timeout const response = await fetch(`${this.backendUrl}/`, { method: 'HEAD', signal: controller.signal }); clearTimeout(timeoutId); return response.ok; } catch (error) { wauthLogger.simple('warn', 'Backend accessibility check failed', error); return false; } } constructor({ dev = false, url, backendUrl }) { this.pb = new PocketBase(url || (dev ? WAuth.devUrl : WAuth.prodUrl)); this.backendUrl = backendUrl || (dev ? WAuth.devBackendUrl : WAuth.prodBackendUrl); this.authData = null; this.wallet = null; this.authRecord = null; this.sessionPassword = null; this.sessionKey = null; wauthLogger.initialization('Initializing', { dev, url: url || (dev ? WAuth.devUrl : WAuth.prodUrl), backendUrl: this.backendUrl }); // Ensure PocketBase auth state is properly restored if (typeof window !== 'undefined') { // Check if there's existing PocketBase auth data in localStorage const existingAuth = localStorage.getItem('pocketbase_auth'); wauthLogger.simple('info', `Checking for existing auth: ${!!existingAuth ? 'Found' : 'Not found'}`); if (existingAuth) { try { // Try to restore the auth state const authData = JSON.parse(existingAuth); if (authData && authData.token && authData.record) { // Set the auth data in PocketBase this.pb.authStore.save(authData.token, authData.record); wauthLogger.sessionUpdate('Restored', { userId: authData.record?.id }); } else { wauthLogger.simple('warn', 'Existing auth data found but invalid format'); } } catch (error) { wauthLogger.simple('warn', 'Failed to restore PocketBase auth state', error); // Don't clear the auth data on parse error, let PocketBase handle it } } } // Load password from session storage on initialization if (typeof window !== 'undefined' && this.hasSessionStorageData()) { this.sessionPasswordLoading = true; this.loadPasswordFromSession().then(async (password) => { if (password) { this.sessionPassword = password; // Try to load wallet if user is already authenticated if (this.isLoggedIn()) { try { this.wallet = await this.getWallet(); const authData = await this.pb.collection("users").authRefresh(); this.authData = authData; this.authRecord = authData.record; console.log("authData", this.authData); console.log("authRecord", this.authRecord); } catch (error) { wauthLogger.simple('warn', 'Could not load wallet after session restore', error); } } } this.sessionPasswordLoading = false; }).catch(error => { wauthLogger.simple('error', 'Failed to load session password', error); this.sessionPasswordLoading = false; }); } this.pb.authStore.onChange(async (token, record) => { this.authRecord = record; this.authData = this.getAuthData(); // Only try to get wallet if we have a session password // This prevents the race condition during connect() if (this.sessionPassword) { try { this.wallet = await this.getWallet(); } catch (error) { wauthLogger.simple('warn', 'Could not get wallet in auth change handler', error); } } this.authDataListeners.forEach(listener => listener(this.getAuthData())); }, true); } onAuthDataChange(callback) { this.authDataListeners.push(callback); if (this.authData) { callback(this.authData); } } getEmail() { if (!this.isLoggedIn()) throw new Error("[wauth] Not logged in"); return { email: this.authRecord?.email, verified: this.authRecord?.verified }; } async runAction(action, payload = {}) { // make sure the user is logged in if (!this.isLoggedIn()) throw new Error("[wauth] Not logged in"); // make sure the wallet is connected if (!this.wallet) this.wallet = await this.getWallet(); if (!this.wallet) throw new Error("[wauth] No wallet found"); // Helper to show modal and await result const showModal = (type, payload) => { return new Promise((resolve) => { this.createModal(type, payload, (result) => { resolve(result); }); }); }; switch (action) { case WalletActions.SIGN: // check for Action=Transfer Tag and ask user for approval if (payload && payload.transaction && payload.transaction.tags) { const actionTag = payload.transaction.tags.find((tag) => tag.name === "Action"); if (actionTag?.value === "Transfer") { // Show modal and await user confirmation const result = await showModal("confirm-tx", { transaction: payload.transaction }); if (!result.proceed) { throw new Error("[wauth] Transaction cancelled by user"); } } } break; case WalletActions.SIGN_DATA_ITEM: // check for Action=Transfer Tag and ask user for approval if (payload && payload.dataItem && payload.dataItem.tags) { const actionTag = payload.dataItem.tags.find((tag) => tag.name === "Action"); if (actionTag?.value === "Transfer") { // Show modal and await user confirmation const result = await showModal("confirm-tx", { dataItem: payload.dataItem }); if (!result.proceed) { throw new Error("[wauth] Transaction cancelled by user"); } } } break; } // Check if wallet is encrypted (password needed) or not const isEncrypted = this.wallet.encrypted; let encryptedPassword = null; if (!isEncrypted) { // Unencrypted wallet - no password needed wauthLogger.simple('info', 'Using unencrypted wallet - no password required'); } else { // Encrypted JWK wallet - password required if (!this.sessionPassword) { // Ask for the password again instead of throwing an error wauthLogger.simple('info', 'Session password not available, requesting from user'); let passwordResult; let attempts = 0; const maxAttempts = 3; let errorMessage = "Session expired. Please enter your password again."; do { wauthLogger.passwordOperation(`action password prompt`, true, attempts + 1); passwordResult = await new Promise((resolve) => { this.createModal("password-existing", { errorMessage }, resolve); }); if (!passwordResult.proceed || !passwordResult.password) { wauthLogger.passwordOperation('entry cancelled', false); throw new Error("[wauth] Password required to continue"); } // CRITICAL: Verify password with backend wauthLogger.passwordOperation(`verification started`, true, attempts + 1); const isValidPassword = await this.verifyPassword(passwordResult.password); if (isValidPassword) { wauthLogger.passwordOperation('verification passed', true); break; // Password is valid, exit loop } else { wauthLogger.passwordOperation(`verification failed`, false, attempts + 1); } attempts++; if (attempts >= maxAttempts) { throw new Error("Too many failed password attempts. Please try again later."); } // Set error message for next modal display errorMessage = `Invalid password. Please try again. (${maxAttempts - attempts} attempts remaining)`; } while (attempts < maxAttempts); wauthLogger.sessionUpdate('Storing verified password for action'); this.sessionPassword = passwordResult.password; await this.storePasswordInSession(passwordResult.password); } encryptedPassword = await PasswordEncryption.encryptPassword(this.sessionPassword, this.backendUrl); } // send the action, payload, and encrypted password to the backend const requestPayload = { action, payload }; // Only include encrypted password if we have one (for encrypted wallets) if (encryptedPassword) { requestPayload.encryptedPassword = encryptedPassword; } const res = await axios.post(`${this.backendUrl}/wallet-action`, requestPayload, { headers: { "Content-Type": "application/json", "Accept": "application/json", "Authorization": `Bearer ${this.getAuthToken()}` }, responseType: 'json' }); return res.data; } // There can be 2 types of modals: // 1. Transaction verification- for when the user is about to transfer tokens and needs user confirmation, // this modal would have info text, amount to be transfered, and proceed/cancel buttons // 2. Password input modal- either when user is connecting for the first time (ask for password and confirm password) // or when they already have an account and just logging in (ask for password), // this will be send in an encrypted way to the backend for use with decoding JWK async createModal(type, payload = {}, callback) { // Prevent multiple modals from showing simultaneously if (this.modalInProgress) { wauthLogger.simple('warn', 'Modal already in progress, ignoring new modal request'); return; } this.modalInProgress = true; // Create a wrapped callback that cleans up the modal flag const wrappedCallback = (result) => { this.modalInProgress = false; callback(result); }; // if type is confirm-tx, check payload.transaction or payload.dataItem and tell the user that some tokens are being transferred and its details, and ask for confirmation // if type is password-new, ask for password and confirm password // if type is password-existing, ask for password and return it // based on the users actions, call the callback with the result const container = createModalContainer(); // Create modal immediately with current payload const modal = createModal(type, payload, (result) => { // Remove the modal container from the DOM after callback if (container.parentNode) { container.parentNode.removeChild(container); } wrappedCallback(result); }); container.appendChild(modal); // Add powered by element as sibling to modal content const powered = document.createElement("div"); powered.className = "wauth-powered"; // Use secure link creation instead of innerHTML const poweredLink = HTMLSanitizer.createSafeLink("https://wauth_subspace.ar.io", "powered by wauth", "_blank"); powered.appendChild(poweredLink); powered.style.position = "absolute"; powered.style.bottom = "20px"; powered.style.textAlign = "center"; powered.style.fontSize = "0.9rem"; powered.style.color = "rgba(255, 255, 255, 0.5)"; powered.style.opacity = "0.8"; powered.style.letterSpacing = "0.5px"; powered.style.fontWeight = "500"; powered.style.left = "0"; powered.style.right = "0"; powered.style.transition = "all 0.2s ease"; powered.style.textShadow = "0 1px 2px rgba(0, 0, 0, 0.5)"; // Style the link directly poweredLink.style.color = "inherit"; poweredLink.style.textDecoration = "none"; poweredLink.style.transition = "all 0.2s ease"; poweredLink.style.borderRadius = "8px"; poweredLink.style.padding = "6px 12px"; poweredLink.style.background = "rgba(255, 255, 255, 0.05)"; poweredLink.style.backdropFilter = "blur(10px)"; poweredLink.style.border = "1px solid rgba(255, 255, 255, 0.1)"; poweredLink.onmouseover = () => { poweredLink.style.color = "rgba(255, 255, 255, 0.9)"; poweredLink.style.background = "rgba(255, 255, 255, 0.1)"; poweredLink.style.borderColor = "rgba(255, 255, 255, 0.2)"; poweredLink.style.transform = "translateY(-1px)"; poweredLink.style.boxShadow = "0 4px 12px rgba(0, 0, 0, 0.3)"; }; poweredLink.onmouseleave = () => { poweredLink.style.color = "rgba(255, 255, 255, 0.5)"; poweredLink.style.background = "rgba(255, 255, 255, 0.05)"; poweredLink.style.borderColor = "rgba(255, 255, 255, 0.1)"; poweredLink.style.transform = "translateY(0)"; poweredLink.style.boxShadow = "none"; }; container.appendChild(powered); document.body.appendChild(container); // Set up focus management after modal is fully added to DOM if (modal._setupFocus) { // Use requestAnimationFrame to ensure modal is fully rendered requestAnimationFrame(() => { modal._setupFocus(); }); } // Now fetch token details asynchronously and update the modal if (type === "confirm-tx") { const data = payload.transaction || payload.dataItem; if (data && data.target) { try { const tokenDetails = await getTokenDetails(data.target); // Update the modal with token details const enhancedPayload = { ...payload, tokenDetails }; const updatedModal = createModal(type, enhancedPayload, (result) => { // Remove the modal container from the DOM after callback if (container.parentNode) { container.parentNode.removeChild(container); } callback(result); }); // Replace the existing modal content (keep powered by element) container.replaceChild(updatedModal, modal); // Set up focus management for the updated modal if (updatedModal._setupFocus) { requestAnimationFrame(() => { updatedModal._setupFocus(); }); } } catch (error) { wauthLogger.simple('warn', 'Failed to fetch token details', error); // Modal continues to work without token details } } } } async connect({ provider, scopes }) { if (!Object.values(WAuthProviders).includes(provider)) throw new Error(`Invalid provider: ${provider}. Valid providers are: ${Object.values(WAuthProviders).join(", ")}`); if (provider === WAuthProviders.Github) { // add scope user:email if not already in scopes if (!scopes?.includes("user:email")) { scopes?.push("user:email"); } } wauthLogger.authStart('OAuth Authentication', provider, { provider, scopes }); try { this.authData = await this.pb.collection("users").authWithOAuth2({ provider, scopes }); this.authDataListeners.forEach(listener => listener(this.getAuthData())); } catch (e) { wauthLogger.authError('OAuth Authentication', e); return null; } if (!this.isLoggedIn()) return null; // Small delay to ensure OAuth process is fully completed await new Promise(resolve => setTimeout(resolve, 100)); // Verify backend connectivity try { const response = await fetch(`${this.backendUrl}/`); wauthLogger.backendRequest('GET', '/', response.status); if (!response.ok) { throw new Error(`Backend not accessible: ${response.status}`); } } catch (error) { wauthLogger.simple('error', 'Backend connectivity check failed', error); throw new Error("Cannot connect to backend server. Please try again later."); } try { // Check if user has an existing wallet const walletCheck = await this.checkExistingWallet(); if (walletCheck.exists && walletCheck.wallet) { // Existing user - check if wallet is encrypted if (!walletCheck.wallet.encrypted) { // Unencrypted wallet - no password needed wauthLogger.simple('info', 'Unencrypted wallet found - no password required'); this.wallet = walletCheck.wallet; } else { // Encrypted wallet - ask for password to decrypt wallet using modal let passwordResult; let attempts = 0; const maxAttempts = 5; // More forgiving attempt limit let errorMessage = ""; do { // Progressive delay based on attempts (exponential backoff) if (attempts > 0) { const delayMs = Math.min(attempts * 1000, 10000); // Max 10 second delay if (delayMs > 0) { const delaySeconds = Math.ceil(delayMs / 1000); errorMessage = `Too many failed attempts. Please wait ${delaySeconds} second${delaySeconds > 1 ? 's' : ''} before trying again.`; // Show countdown in modal const countdownResult = await new Promise((resolve) => { this.createModal("password-existing", { errorMessage }, resolve); }); if (!countdownResult.proceed) { this.clearAllAuthData(false); throw new Error("Password required to access existing wallet"); } // Wait for the delay period await new Promise(resolve => setTimeout(resolve, delayMs)); } } wauthLogger.passwordOperation(`prompt shown`, true, attempts + 1); passwordResult = await new Promise((resolve) => { this.createModal("password-existing", { errorMessage }, resolve); }); if (!passwordResult.proceed || !passwordResult.password) { wauthLogger.passwordOperation('entry cancelled', false); // User cancelled - clear session data but keep PocketBase auth this.clearAllAuthData(false); throw new Error("Password required to access existing wallet"); } // CRITICAL: Verify password before storing it wauthLogger.passwordOperation(`verification started`, true, attempts + 1); const isValidPassword = await this.verifyPassword(passwordResult.password); if (isValidPassword) { wauthLogger.passwordOperation('verification passed', true); break; // Password is valid, exit loop } else { wauthLogger.passwordOperation(`verification failed`, false, attempts + 1); } attempts++; if (attempts >= maxAttempts) { const lockoutMinutes = 5; errorMessage = `Too many failed password attempts. Account temporarily locked for ${lockoutMinutes} minutes. Please check your password manager or contact support if this continues.`; // Show final lockout message await new Promise((resolve) => { this.createModal("password-existing", { errorMessage }, resolve); }); throw new Error(`Too many failed password attempts. Account temporarily locked for ${lockoutMinutes} minutes.`); } // Set error message for next modal display with helpful context const remainingAttempts = maxAttempts - attempts; errorMessage = `Invalid password. Please check your password manager or try a different password. ${remainingAttempts} attempt${remainingAttempts > 1 ? 's' : ''} remaining.`; } while (attempts < maxAttempts); // Store password in session for future use - password is already verified above wauthLogger.sessionUpdate('Storing verified password'); this.sessionPassword = passwordResult.password; await this.storePasswordInSession(passwordResult.password); // Get wallet (password is already verified) this.wallet = await this.getWallet(); } } else { // New user - getWallet will handle wallet creation with appropriate modal wauthLogger.simple('info', 'New user detected, will create wallet when needed'); this.wallet = await this.getWallet(); } if (!this.wallet) { wauthLogger.simple('error', 'No wallet found after authentication'); throw new Error("Failed to create or access wallet"); } wauthLogger.authSuccess('OAuth Authentication', { userId: this.authData?.record?.id, walletAddress: this.wallet.address }); } catch (e) { wauthLogger.authError('OAuth Authentication', e); // Clear all authentication data on error, but be more selective about localStorage // Only clear localStorage if it's a critical error that invalidates the auth const errorMessage = e instanceof Error ? e.message : String(e); const shouldClearLocalStorage = !!errorMessage && (errorMessage.includes("Token is expired") || errorMessage.includes("authentication failed") || errorMessage.includes("invalid token")); this.clearAllAuthData(shouldClearLocalStorage); throw e; } return this.getAuthData(); } async checkExistingWallet() { try { // Ensure we have a user record if (!this.pb.authStore.record?.id) { return { exists: false }; } const userId = this.pb.authStore.record.id; // Use getList instead of getFirstListItem to avoid 404 when no records exist const result = await this.pb.collection("wallets").getList(1, 1, { filter: `user.id = "${userId}"` }); if (result.totalItems > 0) { return { exists: true, wallet: result.items[0] }; } return { exists: false }; } catch (e) { wauthLogger.simple('error', 'Error checking for existing wallet', e); return { exists: false }; } } async verifyPassword(password) { try { wauthLogger.backendRequest('POST', '/verify-password'); // First check if we're logged in if (!this.isLoggedIn()) { wauthLogger.simple('error', 'Cannot verify password: not logged in'); return false; } // Encrypt password for backend const encryptedPassword = await PasswordEncryption.encryptPassword(password, this.backendUrl); wauthLogger.simple('info', 'Password encrypted for backend verification'); // Call verification endpoint const response = await fetch(`${this.backendUrl}/verify-password`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'encrypted-password': encryptedPassword, 'Authorization': `Bearer ${this.getAuthToken()}` } }); if (!response.ok) { wauthLogger.backendRequest('POST', '/verify-password', response.status); return false; } const result = await response.json(); const isValid = result.valid === true; wauthLogger.simple('info', `Password verification result: ${isValid ? "VALID" : "INVALID"}`); return isValid; } catch (error) { wauthLogger.simple('error', 'Password verification failed', error); return false; } } // Enhanced password verification helper with debugging async verifyAndStorePassword(password, context) { wauthLogger.simple('info', `${context}: Verifying password with backend before storing`); const isValidPassword = await this.verifyPassword(password); if (!isValidPassword) { wauthLogger.simple('error', `${context}: Password verification FAILED - will not store invalid password`); return false; } wauthLogger.sessionUpdate(`${context}: Password verification PASSED, storing in session`); this.sessionPassword = password; await this.storePasswordInSession(password); return true; } async addConnectedWallet(address, pkey, signature) { if (!this.isLoggedIn()) throw new Error("Not logged in"); if (!this.wallet) this.wallet = await this.getWallet(); if (!this.wallet) throw new Error("No wallet found"); const token = this.getAuthToken(); if (!token) throw new Error("No auth token"); const res = await fetch(`${this.backendUrl}/connect-wallet`, { method: "POST", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` }, body: JSON.stringify({ address, pkey, signature }) }); const data = await res.json(); return data; } isLoggedIn() { const isValid = this.pb.authStore.isValid; return isValid; } async getActiveAddress() { if (!this.isLoggedIn()) throw new Error("Not logged in"); if (!this.wallet) this.wallet = await this.getWallet(); return this.wallet?.address || ""; } async getActivePublicKey() { if (!this.isLoggedIn()) throw new Error("Not logged in"); if (!this.wallet) this.wallet = await this.getWallet(); return this.wallet?.public_key || ""; } async getPermissions() { return ["ACCESS_ADDRESS", "SIGN_TRANSACTION"]; } async getWalletNames() { return { [await this.getActiveAddress()]: "WAuth" }; } async getArweaveConfig() { // TODO: make this configurable const config = { host: "arweave.net", port: 443, protocol: "https", }; return config; } getAuthData() { if (!this.isLoggedIn()) return null; return this.authData; } getAuthToken() { if (!this.isLoggedIn()) return null; if (!this.pb.authStore.token) return null; return this.pb.authStore.token; } async getWallet(showPasswordModal = true) { if (!this.isLoggedIn()) { return null; } // First, check if we already have a wallet and if it's unencrypted if (this.wallet && !this.wallet.encrypted) { wauthLogger.simple('info', 'Unencrypted wallet already loaded - no password required'); return this.wallet; } // Ensure we have a user record if (!this.pb.authStore.record?.id) { throw new Error("[wauth] User record not available - please log in again"); } const userId = this.pb.authStore.record.id; // First, try to get the wallet from the database to check its type try { const result = await this.pb.collection("wallets").getList(1, 1, { filter: `user.id = "${userId}"` }); wauthLogger.walletOperation('Query', { userId, walletsFound: result.totalItems }); if (result.totalItems > 0) { // Existing wallet found wauthLogger.walletOperation('Using existing wallet', { walletId: result.items[0].id }); this.wallet = result.items[0]; // Check if this is an unencrypted wallet - if so, no password needed if (!this.wallet.encrypted) { wauthLogger.simple('info', 'Unencrypted wallet found - no password required'); return this.wallet; } // For encrypted wallets, check if we need to ask for password if (this.wallet.encrypted && !this.sessionPassword && showPasswordModal) { wauthLogger.simple('info', 'Encrypted wallet found but no session password - requesting password'); let passwordResult; let attempts = 0; const maxAttempts = 3; let errorMessage = "Please enter your password to access your wallet."; do { wauthLogger.passwordOperation(`encrypted wallet password prompt`, true, attempts + 1); passwordResult = await new Promise((resolve) => { this.createModal("password-existing", { errorMessage }, resolve); }); if (!passwordResult.proceed || !passwordResult.password) { wauthLogger.passwordOperation('entry cancelled', false); throw new Error("[wauth] Password required to access encrypted wallet"); } // CRITICAL: Verify password with backend wauthLogger.passwordOperation(`verification started`, true, attempts + 1); const isValidPassword = await this.verifyPassword(passwordResult.password); if (isValidPassword) { wauthLogger.passwordOperation('verification passed', true); break; // Password is valid, exit loop } else { wauthLogger.passwordOperation(`verification failed`, false, attempts + 1); } attempts++; if (attempts >= maxAttempts) { throw new Error("Too many failed password attempts. Please try again later."); } // Set error message for next modal display errorMessage = `Invalid password. Please try again. (${maxAttempts - attempts} attempts remaining)`; } while (attempts < maxAttempts); wauthLogger.sessionUpdate('Storing verified password for encrypted wallet'); this.sessionPassword = passwordResult.password; await this.storePasswordInSession(passwordResult.password); } return this.wallet; } else { // No wallet exists, will create one below wauthLogger.walletOperation('No wallet found, will create one', { userId }); } } catch (e) { wauthLogger.simple('error', 'Error querying wallet', e.message || e); throw e; } // If we reach here, we need to create a new wallet if (!this.wallet) { // No wallet exists, create one wauthLogger.walletOperation('Creating new wallet', { userId }); // Double-check that no wallet exists before creating (prevent race conditions) const doubleCheckResult = await this.pb.collection("wallets").getList(1, 1, { filter: `user.id = "${userId}"` }); if (doubleCheckResult.totalItems > 0) { wauthLogger.walletOperation('Using wallet created by another process', { walletId: doubleCheckResult.items[0].id }); this.wallet = doubleCheckResult.items[0]; return this.wallet; } // For new wallet creation, show the password creation modal if (showPasswordModal) { wauthLogger.simple('info', 'No wallet exists, requesting password for new wallet creation'); const result = await new Promise((resolve) => { this.createModal("password-new", {}, resolve); }); if (!result.proceed) { wauthLogger.passwordOperation('new password creation cancelled', false); throw new Error("[wauth] Password required to create wallet"); } if (result.skipPassword) { wauthLogger.simple('info', 'User chose to skip password - creating unencrypted wallet'); // Create wallet without password this.wallet = await this.createWalletWithoutPassword(); return this.wallet; } else if (result.password) { wauthLogger.sessionUpdate('Storing new password for wallet creation'); this.sessionPassword = result.password; await this.storePasswordInSession(result.password); } else { wauthLogger.passwordOperation('new password creation cancelled', false); throw new Error("[wauth] Password required to create wallet"); } } else { throw new Error("[wauth] Password required to create wallet"); } const encryptedPassword = await PasswordEncryption.encryptPassword(this.sessionPassword, this.backendUrl); const encryptedConfirmPassword = await PasswordEncryption.encryptPassword(this.sessionPassword, this.backendUrl); wauthLogger.backendRequest('POST', '/wallets'); await this.pb.collection("wallets").create({}, { headers: { "encrypted-password": encryptedPassword, "encrypted-confirm-password": encryptedConfirmPassword } }); // Use getList instead of getFirstListItem to avoid 404 if creation failed const createdResult = await this.pb.collection("wallets").getList(1, 1, { filter: `user.id = "${userId}"` }); if (createdResult.totalItems === 0) { throw new Error("[wauth] Failed to create wallet - no record found after creation"); } wauthLogger.walletOperation('Successfully created wallet', { walletId: createdResult.items[0].id }); this.wallet = createdResult.items[0]; return this.wallet; } // If we have an encrypted wallet, return it (password was already handled above) return this.wallet; } async createWalletWithoutPassword() { if (!this.isLoggedIn()) { throw new Error("[wauth] User not logged in"); } if (!this.pb.authStore.record?.id) { throw new Error("[wauth] User record not available"); } const userId = this.pb.authStore.record.id; try { wauthLogger.walletOperation('Creating wallet without password', { userId }); // Double-check that no wallet exists before creating (prevent race conditions) const doubleCheckResult = await this.pb.collection("wallets").getList(1, 1, { filter: `user.id = "${userId}"` }); if (doubleCheckResult.totalItems > 0) { wauthLogger.walletOperation('Using wallet created by another process', { walletId: doubleCheckResult.items[0].id }); return doubleCheckResult.items[0]; } wauthLogger.backendRequest('POST', '/wallets'); await this.pb.collection("wallets").create({}, { headers: { "skip-password": "true" } }); // Use getList instead of getFirstListItem to avoid 404 if creation failed const createdResult = await this.pb.collection("wallets").getList(1, 1, { filter: `user.id = "${userId}"` }); if (createdResult.totalItems === 0) { throw new Error("[wauth] Failed to create wallet - no record found after creation"); } wauthLogger.walletOperation('Successfully created wallet without password', { walletId: createdResult.items[0].id }); return createdResult.items[0]; } catch (e) { wauthLogger.simple('error', 'Error creating wallet without password', e.message || e); throw e; } } async getConnectedWallets() { const res = await this.pb.collection("connected_wallets").getFullList({ filter: `user.id = "${this.pb.authStore.record?.id}"` }); return res; } async removeConnectedWallet(walletId) { if (!this.isLoggedIn()) throw new Error("Not logged in"); try { // First verify the wallet belongs to the current user const wallet = await this.pb.collection("connected_wallets").getOne(walletId, { filter: `user.id = "${this.pb.authStore.record?.id}"` }); if (!wallet) { throw new Error("[wauth] Wallet not found or not owned by current user"); } // Delete the wallet record await this.pb.collection("connected_wallets").delete(walletId); return { success: true, walletId }; } catch (error) { wauthLogger.simple('error', 'Error removing connected wallet', error); throw error; } } getAuthRecord() { if (!this.isLoggedIn()) return null; return this.authRecord; } getUsername() { if (!this.isLoggedIn()) return null; const authData = this.getAuthData(); const username = authData?.meta?.username || null; if (!username) { console.warn("Username not found in auth data, refresh authentication"); } return username; } pocketbase() { return this.pb; } async sign(transaction, options) { if (options) wauthLogger.simple('warn', '