@wauth/sdk
Version:
Web2 auth sdk for Arweave
1,022 lines • 57.3 kB
JavaScript
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', '