UNPKG

bigblocks

Version:

Complete Bitcoin UI component library - authentication, social, wallet, market, inscriptions, and blockchain interactions for React

968 lines (967 loc) 41.2 kB
import { HD, Mnemonic } from "@bsv/sdk"; import { getAuthToken } from "bitcoin-auth"; import { decryptBackup, encryptBackup, } from "bitcoin-backup"; import { BAP } from "bsv-bap"; import { DEFAULT_ALLOWED_BACKUP_TYPES, createUnsupportedBackupError, detectBackupType, isBackupTypeAllowed, } from "../lib/backup-utils.js"; import { isBapMasterBackupLegacy, isMasterBackupType42 } from "../lib/types.js"; import { BACKEND_ERRORS, ErrorType } from "../lib/types.js"; import { createStorageKeys } from "./storage-keys.js"; import { createLocalStorage, createSessionStorage } from "./storage/index.js"; export class AuthManager { config; persistentStorage; sessionStorage; storageKeys; listeners = new Set(); state; constructor(config) { this.config = { apiUrl: config.apiUrl || "/api", oauthProviders: config.oauthProviders || ["google", "github"], customOAuthProviders: config.customOAuthProviders || [], backupTypes: config.backupTypes || { enabled: DEFAULT_ALLOWED_BACKUP_TYPES, }, persistentStorage: config.persistentStorage || createLocalStorage({ prefix: config.storageNamespace }), sessionStorage: config.sessionStorage || createSessionStorage({ prefix: config.storageNamespace }), storageNamespace: config.storageNamespace, theme: config.theme || { mode: "dark" }, redirects: config.redirects || {}, onSuccess: config.onSuccess || (() => { }), onError: config.onError || (() => { }), }; this.persistentStorage = this.config.persistentStorage; this.sessionStorage = this.config.sessionStorage; this.storageKeys = createStorageKeys(this.config.storageNamespace); // Initialize state this.state = { user: null, isAuthenticated: false, isLoading: false, error: null, currentStep: "initial", mode: "signin", hasLocalBackup: false, backupStatus: { hasLocal: false, hasCloud: false, cloudProviders: [], lastUpdated: undefined, }, hasWalletCapabilities: false, linkedProviders: [], pendingOAuthProvider: null, }; // Check for existing backup on init this.checkExistingBackup(); // Check for wallet capabilities on init this.checkWalletCapabilities(); } // Event Management subscribe(listener) { this.listeners.add(listener); return () => this.listeners.delete(listener); } emit(event) { for (const listener of this.listeners) { listener(event); } } // State Management getState() { return { ...this.state }; } async checkExistingBackup() { const encryptedBackup = await this.persistentStorage.get(this.storageKeys.encryptedBackup); this.state.hasLocalBackup = !!encryptedBackup; this.state.backupStatus.hasLocal = !!encryptedBackup; } // Core Authentication Actions async signIn(password) { this.emit({ type: "AUTH_STARTED" }); this.state.isLoading = true; this.state.error = null; try { let decryptedBackup; // Check for encrypted backup first const encryptedBackup = await this.persistentStorage.get(this.storageKeys.encryptedBackup); if (encryptedBackup) { // Decrypt the encrypted backup decryptedBackup = await this.decryptBackup(encryptedBackup, password); } else { // Check for session backup (unencrypted backup import) const sessionBackup = await this.sessionStorage.get(this.storageKeys.sessionDecryptedBackup); if (sessionBackup) { const unencryptedBackup = JSON.parse(sessionBackup); // Encrypt it with the provided password and store as main backup const encrypted = await encryptBackup(unencryptedBackup, password); await this.persistentStorage.set(this.storageKeys.encryptedBackup, encrypted); await this.sessionStorage.delete(this.storageKeys.sessionDecryptedBackup); // Update backup status this.state.hasLocalBackup = true; this.state.backupStatus.hasLocal = true; decryptedBackup = unencryptedBackup; } else { throw this.createError("BACKUP_NOT_FOUND", "No backup found. Please create an account first."); } } // Extract identity and create session const user = await this.createSession(decryptedBackup); this.state.user = user; this.state.isAuthenticated = true; this.state.currentStep = "success"; // Check if wallet capabilities are available await this.checkWalletCapabilities(); this.emit({ type: "AUTH_SUCCESS", user }); this.config.onSuccess?.(user); } catch (error) { const authError = this.normalizeError(error); this.state.error = authError; this.emit({ type: "AUTH_FAILED", error: authError }); this.config.onError?.(authError); throw authError; } finally { this.state.isLoading = false; } } async signUp(password) { this.emit({ type: "AUTH_STARTED" }); this.state.isLoading = true; this.state.error = null; try { // Generate new identity const backup = this.generateBackup(); this.emit({ type: "BACKUP_GENERATED", backup }); // Encrypt and store const encryptedBackup = await encryptBackup(backup, password); await this.persistentStorage.set(this.storageKeys.encryptedBackup, encryptedBackup); // Create session const user = await this.createSession(backup); this.state.user = user; this.state.isAuthenticated = true; this.state.hasLocalBackup = true; this.state.backupStatus.hasLocal = true; this.state.currentStep = "oauth-link"; this.emit({ type: "AUTH_SUCCESS", user }); this.config.onSuccess?.(user); } catch (error) { const authError = this.normalizeError(error); this.state.error = authError; this.emit({ type: "AUTH_FAILED", error: authError }); this.config.onError?.(authError); throw authError; } finally { this.state.isLoading = false; } } async signOut() { // Clear all session storage (temporary data) await this.sessionStorage.delete(this.storageKeys.sessionDecryptedBackup); await this.sessionStorage.delete(this.storageKeys.sessionPaymentKey); await this.sessionStorage.delete(this.storageKeys.sessionOrdinalKey); await this.sessionStorage.delete(this.storageKeys.sessionOAuthState); // Note: We intentionally preserve encrypted backups in local storage: // - encryptedBackup (user can sign back in with password) // - bap-rotation-{idKey} (BAP rotation history) // Reset state this.state.user = null; this.state.isAuthenticated = false; this.state.currentStep = "initial"; this.state.mode = "signin"; this.state.error = null; // Call API to clear server session if needed try { const response = await fetch(`${this.config.apiUrl}/auth/signout`, { method: "POST", credentials: "include", }); if (!response.ok) { console.error("Failed to sign out on server"); } } catch (error) { console.error("Error signing out:", error); } } // Backup Management generateBackup() { // Generate a new identity const mnemonic = Mnemonic.fromRandom(); const seed = mnemonic.toSeed(); const hdKey = HD.fromSeed(seed); const xprv = hdKey.toString(); const bap = new BAP(xprv); bap.newId(); // Create root identity const ids = bap.exportIds(); const backup = { xprv, mnemonic: mnemonic.toString(), ids, createdAt: new Date().toISOString(), }; return backup; } // Store a generated backup in session for the signup flow async storeGeneratedBackupForSignup(backup) { await this.sessionStorage.set(this.storageKeys.sessionDecryptedBackup, JSON.stringify(backup)); await this.sessionStorage.set(this.storageKeys.sessionBackupSource, "generated"); } async importBackup(file) { try { const text = await file.text(); // Security-first approach: NEVER store unencrypted data in localStorage // Following reference implementation pattern // First, try to determine if it's encrypted or unencrypted let isUnencrypted = false; let parsedBackup = null; try { parsedBackup = JSON.parse(text); // Check if it looks like an unencrypted backup (has unencrypted fields) if (parsedBackup && typeof parsedBackup === "object") { const backup = parsedBackup; // Detect unencrypted patterns (mnemonic, private keys, etc.) if (backup.mnemonic || backup.xprv || backup.rootPk || backup.wif || backup.ordPk) { isUnencrypted = true; } } } catch { // Not valid JSON, check if it's a plain WIF string const trimmedText = text.trim(); try { // Try to create a PrivateKey from it to verify it's valid WIF const { PrivateKey } = await import("@bsv/sdk"); PrivateKey.fromWif(trimmedText); // It's a valid WIF string, wrap it in a WifBackup object parsedBackup = { wif: trimmedText }; isUnencrypted = true; } catch { // Not a valid WIF, assume it's an encrypted string isUnencrypted = false; } } if (isUnencrypted && parsedBackup) { // SECURITY: For unencrypted backups, we must IMMEDIATELY prompt for password // and never store the unencrypted data anywhere // Validate backup type is allowed const backupType = detectBackupType(parsedBackup); const allowedTypes = this.config.backupTypes?.enabled || DEFAULT_ALLOWED_BACKUP_TYPES; if (!backupType || !isBackupTypeAllowed(backupType, allowedTypes)) { const errorMessage = createUnsupportedBackupError(backupType, allowedTypes, this.config.backupTypes?.errorMessages?.unsupported); throw this.createError("UNSUPPORTED_BACKUP", errorMessage); } // Store ONLY in session storage temporarily for password prompt // Store the JSON string representation of the parsed backup await this.sessionStorage.set(this.storageKeys.sessionDecryptedBackup, JSON.stringify(parsedBackup)); await this.sessionStorage.set(this.storageKeys.sessionBackupSource, "imported"); this.state.currentStep = "password"; this.emit({ type: "BACKUP_IMPORTED" }); return; } // For encrypted backups, validate format let isValidEncrypted = false; try { // Check if it's a JSON with encrypted fields if (parsedBackup && typeof parsedBackup === "object") { const backup = parsedBackup; if (backup.encrypted || backup.encryptedMnemonic) { isValidEncrypted = true; } } } catch { // Check if it's a plain encrypted string if (text.length > 100 && /^[A-Za-z0-9+/=]+$/.test(text.trim())) { isValidEncrypted = true; } } if (!isValidEncrypted) { throw this.createError("INVALID_BACKUP", "Invalid backup file format"); } // Store encrypted backup safely in localStorage await this.persistentStorage.set(this.storageKeys.encryptedBackup, text.trim()); this.state.hasLocalBackup = true; this.state.backupStatus.hasLocal = true; this.state.currentStep = "password"; this.emit({ type: "BACKUP_IMPORTED" }); } catch (error) { if (error && typeof error === "object" && "code" in error) { throw error; } throw this.createError("INVALID_BACKUP", "Failed to import backup file"); } } isBase64String(str) { // Check if string looks like Base64 (alphanumeric + / + =) const base64Regex = /^[A-Za-z0-9+/]*={0,2}$/; return str.length > 20 && base64Regex.test(str) && str.length % 4 === 0; } isValidWif(str) { // Basic WIF validation - starts with 5, K, or L and is 51-52 characters return /^[5KL][1-9A-HJ-NP-Za-km-z]{50,51}$/.test(str); } isValidBackupFormat(data) { if (!data || typeof data !== "object") { return false; } const backup = data; // Check for different backup types return ( // BapMasterBackup (Legacy) (typeof backup.xprv === "string" && typeof backup.mnemonic === "string") || // BapMasterBackup (Type 42) (typeof backup.rootPk === "string" && typeof backup.ids === "string" && !backup.xprv) || // BapMemberBackup (typeof backup.wif === "string" && typeof backup.id === "string") || // WifBackup (typeof backup.wif === "string" && !backup.id) || // OneSatBackup (typeof backup.ordPk === "string" && typeof backup.payPk === "string" && typeof backup.identityPk === "string")); } async validatePassword(password) { try { const encryptedBackup = await this.persistentStorage.get(this.storageKeys.encryptedBackup); if (!encryptedBackup) return false; await this.decryptBackup(encryptedBackup, password); return true; } catch { return false; } } // OAuth Management async linkOAuth(provider) { this.state.pendingOAuthProvider = provider; // Store current state for OAuth flow const oauthState = { mode: "link", provider, timestamp: Date.now(), }; await this.sessionStorage.set(this.storageKeys.sessionOAuthState, JSON.stringify(oauthState)); // Redirect to OAuth provider window.location.href = `${this.config.apiUrl}/auth/link-provider?provider=${provider}`; } async handleOAuthCallback(provider, success) { if (success) { this.state.linkedProviders.push(provider); this.state.backupStatus.cloudProviders.push(provider); this.state.backupStatus.hasCloud = true; this.emit({ type: "OAUTH_LINKED", provider }); } this.state.pendingOAuthProvider = null; await this.sessionStorage.delete(this.storageKeys.sessionOAuthState); } // Helper Methods async decryptBackup(encryptedBackup, password) { try { // Use the encrypted backup string directly - bitcoin-backup handles the format detection const decrypted = await decryptBackup(encryptedBackup, password); // Validate it's a supported backup type if (!decrypted || typeof decrypted !== "object") { throw new Error("Invalid backup format"); } // Check if the backup type is allowed const backupType = detectBackupType(decrypted); const allowedTypes = this.config.backupTypes?.enabled || DEFAULT_ALLOWED_BACKUP_TYPES; if (!backupType || !isBackupTypeAllowed(backupType, allowedTypes)) { const errorMessage = createUnsupportedBackupError(backupType, allowedTypes, this.config.backupTypes?.errorMessages?.unsupported); throw this.createError("UNSUPPORTED_BACKUP", errorMessage); } return decrypted; } catch (error) { // Check if it's our custom error if (error && typeof error === "object" && "code" in error) { throw error; } throw this.createError("INVALID_PASSWORD", "Incorrect password or corrupted backup"); } } async createSession(backup) { const backupType = detectBackupType(backup); switch (backupType) { case "BapMasterBackup": return this.createSessionFromBapMaster(this.validateBackupType(backup, "BapMasterBackup")); case "OneSatBackup": return this.createSessionFromOneSat(this.validateBackupType(backup, "OneSatBackup")); case "BapMemberBackup": return this.createSessionFromBapMember(this.validateBackupType(backup, "BapMemberBackup")); case "WifBackup": return this.createSessionFromWif(this.validateBackupType(backup, "WifBackup")); default: throw this.createError("UNSUPPORTED_BACKUP", "This backup type is not supported for full authentication"); } } validateBackupType(backup, expectedType) { const detectedType = detectBackupType(backup); if (detectedType !== expectedType) { throw this.createError("INVALID_BACKUP", `Expected ${expectedType}, but detected ${detectedType}`); } return backup; } async createSessionFromBapMaster(backup) { // Extract identity from backup - handle both legacy and Type 42 formats let bap; if (isBapMasterBackupLegacy(backup)) { // Legacy format: use xprv bap = new BAP(backup.xprv); } else if (isMasterBackupType42(backup)) { // Type 42 format: use rootPk bap = new BAP({ rootPk: backup.rootPk }); } else { throw this.createError("INVALID_BACKUP", "Invalid master backup format"); } bap.importIds(backup.ids); const ids = bap.listIds(); if (!ids || ids.length === 0 || ids[0] === undefined) { throw this.createError("INVALID_BACKUP", "No identities found in backup"); } const primaryId = ids[0]; const master = bap.getId(primaryId); if (!master) { throw this.createError("INVALID_BACKUP", "No identity found in backup"); } const memberBackup = master.exportMemberBackup(); if (!memberBackup?.derivedPrivateKey) { throw this.createError("INVALID_BACKUP", "No private key found in backup"); } const address = master.getCurrentAddress(); if (!address) { throw this.createError("INVALID_BACKUP", "No address found for identity"); } // Store decrypted backup in session await this.sessionStorage.set(this.storageKeys.sessionDecryptedBackup, JSON.stringify(backup)); // Create auth token const authToken = getAuthToken({ privateKeyWif: memberBackup.derivedPrivateKey, requestPath: `${this.config.apiUrl}/auth/signin`, body: "", }); // Call API to create session with better error handling let response; try { response = await fetch(`${this.config.apiUrl}/auth/signin`, { method: "POST", headers: { "Content-Type": "application/json", "X-Auth-Token": authToken, }, body: JSON.stringify({ address, idKey: primaryId }), credentials: "include", }); } catch (_error) { throw this.createBackendError("NETWORK_ERROR", BACKEND_ERRORS.CONNECTION_FAILED); } if (!response.ok) { if (response.status === 404) { throw this.createBackendError("API_ERROR", BACKEND_ERRORS.ENDPOINT_NOT_FOUND); } const errorText = await response.text().catch(() => "Unknown error"); throw this.createError("API_ERROR", `Authentication failed: ${response.status} ${errorText}`); } // Create user object const user = { id: primaryId, address, idKey: primaryId, profiles: ids.map((id) => { const profileMaster = bap.getId(id); return { id, address: profileMaster?.getCurrentAddress() || "", isPublished: false, }; }), activeProfileId: primaryId, }; return user; } async createSessionFromOneSat(backup) { // For OneSat backups, use identityPk for authentication const identityWif = backup.identityPk; // Store decrypted backup in session await this.sessionStorage.set(this.storageKeys.sessionDecryptedBackup, JSON.stringify(backup)); // Store payment key for wallet functionality await this.sessionStorage.set(this.storageKeys.sessionPaymentKey, backup.payPk); await this.sessionStorage.set(this.storageKeys.sessionOrdinalKey, backup.ordPk); // Generate address from identity key for user ID const { PrivateKey } = await import("@bsv/sdk"); const identityKey = PrivateKey.fromWif(identityWif); const address = identityKey.toPublicKey().toAddress(); const idKey = identityKey.toPublicKey().toString(); // Create auth token using identity key const authToken = getAuthToken({ privateKeyWif: identityWif, requestPath: `${this.config.apiUrl}/auth/signin`, body: "", }); // Call API to create session with better error handling let response; try { response = await fetch(`${this.config.apiUrl}/auth/signin`, { method: "POST", headers: { "Content-Type": "application/json", "X-Auth-Token": authToken, }, body: JSON.stringify({ address, idKey }), credentials: "include", }); } catch (_error) { throw this.createBackendError("NETWORK_ERROR", BACKEND_ERRORS.CONNECTION_FAILED); } if (!response.ok) { if (response.status === 404) { throw this.createBackendError("API_ERROR", BACKEND_ERRORS.ENDPOINT_NOT_FOUND); } const errorText = await response.text().catch(() => "Unknown error"); throw this.createError("API_ERROR", `Authentication failed: ${response.status} ${errorText}`); } // Create user object with single profile const user = { id: idKey, address, idKey, profiles: [ { id: idKey, address, isPublished: false, }, ], activeProfileId: idKey, }; return user; } async createSessionFromBapMember(backup) { // BapMemberBackup has wif and id properties const privateKeyWif = backup.wif; const idKey = backup.id; if (!privateKeyWif || !idKey) { throw this.createError("INVALID_BACKUP", "Invalid member backup format - missing required fields"); } // Store decrypted backup in session await this.sessionStorage.set(this.storageKeys.sessionDecryptedBackup, JSON.stringify(backup)); // Generate address from private key const { PrivateKey } = await import("@bsv/sdk"); const privateKey = PrivateKey.fromWif(privateKeyWif); const address = privateKey.toPublicKey().toAddress(); // Create auth token const authToken = getAuthToken({ privateKeyWif, requestPath: `${this.config.apiUrl}/auth/signin`, body: "", }); // Call API to create session with better error handling let response; try { response = await fetch(`${this.config.apiUrl}/auth/signin`, { method: "POST", headers: { "Content-Type": "application/json", "X-Auth-Token": authToken, }, body: JSON.stringify({ address, idKey }), credentials: "include", }); } catch (_error) { throw this.createBackendError("NETWORK_ERROR", BACKEND_ERRORS.CONNECTION_FAILED); } if (!response.ok) { if (response.status === 404) { throw this.createBackendError("API_ERROR", BACKEND_ERRORS.ENDPOINT_NOT_FOUND); } const errorText = await response.text().catch(() => "Unknown error"); throw this.createError("API_ERROR", `Authentication failed: ${response.status} ${errorText}`); } // Create user object const user = { id: idKey, address, idKey, profiles: [ { id: idKey, address, alternateName: idKey.slice(0, 8), image: "", isPublished: false, }, ], activeProfileId: idKey, }; return user; } async createSessionFromWif(backup) { const privateKeyWif = backup.wif; if (!privateKeyWif) { throw this.createError("INVALID_BACKUP", "Invalid WIF backup format - missing private key"); } // Store decrypted backup in session await this.sessionStorage.set(this.storageKeys.sessionDecryptedBackup, JSON.stringify(backup)); // Generate address and public key from WIF const { PrivateKey } = await import("@bsv/sdk"); const privateKey = PrivateKey.fromWif(privateKeyWif); const publicKey = privateKey.toPublicKey(); const address = publicKey.toAddress(); const idKey = publicKey.toString(); // Create auth token const authToken = getAuthToken({ privateKeyWif, requestPath: `${this.config.apiUrl}/auth/signin`, body: "", }); // Call API to create session with better error handling let response; try { response = await fetch(`${this.config.apiUrl}/auth/signin`, { method: "POST", headers: { "Content-Type": "application/json", "X-Auth-Token": authToken, }, body: JSON.stringify({ address, idKey }), credentials: "include", }); } catch (_error) { throw this.createBackendError("NETWORK_ERROR", BACKEND_ERRORS.CONNECTION_FAILED); } if (!response.ok) { if (response.status === 404) { throw this.createBackendError("API_ERROR", BACKEND_ERRORS.ENDPOINT_NOT_FOUND); } const errorText = await response.text().catch(() => "Unknown error"); throw this.createError("API_ERROR", `Authentication failed: ${response.status} ${errorText}`); } // Create user object with minimal profile const user = { id: idKey, address, idKey, profiles: [ { id: idKey, address, alternateName: address.slice(0, 8), image: "", isPublished: false, }, ], activeProfileId: idKey, }; return user; } async checkWalletCapabilities() { try { // Check if payment key is available in session storage const paymentKey = await this.sessionStorage.get(this.storageKeys.sessionPaymentKey); this.state.hasWalletCapabilities = !!paymentKey; } catch { this.state.hasWalletCapabilities = false; } } // Simple method to check if we have an unencrypted backup waiting to be encrypted async hasUnencryptedBackupInSession() { try { const sessionBackup = await this.sessionStorage.get(this.storageKeys.sessionDecryptedBackup); return !!sessionBackup; } catch { return false; } } // Check if the current session backup was imported (vs generated) async isSessionBackupImported() { try { const importFlag = await this.sessionStorage.get(this.storageKeys.sessionBackupSource); return importFlag === "imported"; } catch { return false; } } // Simple method to check if we have an encrypted backup stored async hasEncryptedBackupStored() { try { const encryptedBackup = await this.persistentStorage.get(this.storageKeys.encryptedBackup); return !!encryptedBackup; } catch { return false; } } async getWalletExtension() { if (!this.state.hasWalletCapabilities) { return null; } try { const paymentKey = await this.sessionStorage.get(this.storageKeys.sessionPaymentKey); const ordinalKey = await this.sessionStorage.get(this.storageKeys.sessionOrdinalKey); if (!paymentKey) { return null; } return { paymentKey, ordinalKey: ordinalKey || undefined, // Note: Balance and UTXOs would need to be fetched from API // This just provides the key management part }; } catch (error) { console.warn("Failed to get wallet extension:", error); return null; } } createError(code, message) { return { code, message }; } createBackendError(code, message) { const error = { message, type: ErrorType.BACKEND_SETUP, code: code, }; return error; } normalizeError(error) { if (error && typeof error === "object" && "code" in error) { return error; } if (error instanceof Error) { if (error.name === "AbortError" || error.message.includes("timeout")) { return { code: "NETWORK_ERROR", message: "Request timeout. Check if your backend is running and the /api/auth/signin endpoint exists.", details: error, }; } return { code: "UNKNOWN_ERROR", message: error.message, details: error, }; } return { code: "UNKNOWN_ERROR", message: "An unexpected error occurred", details: error, }; } // Action implementations for the interface getActions() { return { signIn: this.signIn.bind(this), signUp: this.signUp.bind(this), signOut: this.signOut.bind(this), generateBackup: this.generateBackup.bind(this), importBackup: this.importBackup.bind(this), exportBackup: async (_password) => { const encryptedBackup = await this.persistentStorage.get(this.storageKeys.encryptedBackup); if (!encryptedBackup) { throw this.createError("BACKUP_NOT_FOUND", "No backup found to export"); } const blob = new Blob([encryptedBackup], { type: "application/json" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `bitcoin-identity-backup-${new Date().toISOString().split("T")[0]}.json`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); }, validatePassword: this.validatePassword.bind(this), getCurrentBackup: async () => { if (!this.state.isAuthenticated) { return null; } try { const decryptedBackup = await this.sessionStorage.get(this.storageKeys.sessionDecryptedBackup); if (!decryptedBackup) { return null; } const backup = JSON.parse(decryptedBackup); return backup; } catch { return null; } }, linkOAuth: this.linkOAuth.bind(this), unlinkOAuth: async (provider) => { if (!this.state.isAuthenticated || !this.state.user) { throw this.createError("AUTH_REQUIRED", "Must be authenticated to unlink OAuth"); } // Call API to unlink provider const response = await fetch(`${this.config.apiUrl}/users/disconnect-account`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ provider }), credentials: "include", }); if (!response.ok) { throw this.createError("API_ERROR", "Failed to unlink OAuth provider"); } // Update state this.state.linkedProviders = this.state.linkedProviders.filter((p) => p !== provider); this.state.backupStatus.cloudProviders = this.state.backupStatus.cloudProviders.filter((p) => p !== provider); this.emit({ type: "OAUTH_UNLINKED", provider }); }, restoreFromOAuth: async (provider) => { // Store OAuth restore state const oauthState = { mode: "restore", provider, timestamp: Date.now(), }; await this.sessionStorage.set(this.storageKeys.sessionOAuthState, JSON.stringify(oauthState)); // Redirect to OAuth provider window.location.href = `${this.config.apiUrl}/auth/signin?provider=${provider}`; }, createProfile: async () => { if (!this.state.isAuthenticated || !this.state.user) { throw this.createError("AUTH_REQUIRED", "Must be authenticated to create profile"); } const decryptedBackup = await this.sessionStorage.get(this.storageKeys.sessionDecryptedBackup); if (!decryptedBackup) { throw this.createError("BACKUP_NOT_FOUND", "No backup found"); } const backup = JSON.parse(decryptedBackup); let bap; if (isBapMasterBackupLegacy(backup)) { bap = new BAP(backup.xprv); } else if (isMasterBackupType42(backup)) { bap = new BAP({ rootPk: backup.rootPk }); } else { throw this.createError("INVALID_BACKUP", "Invalid master backup format"); } bap.importIds(backup.ids); // Create new profile const newId = bap.newId(); const newBackup = { ...backup, ids: bap.exportIds(), }; // Update storage await this.sessionStorage.set(this.storageKeys.sessionDecryptedBackup, JSON.stringify(newBackup)); // Create profile info const profileInfo = { id: newId.identityKey, address: newId.getCurrentAddress(), isPublished: false, }; // Update user state this.state.user.profiles.push(profileInfo); this.emit({ type: "PROFILE_CREATED", profile: profileInfo }); return profileInfo; }, switchProfile: (profileId) => { if (!this.state.user) return; const profile = this.state.user.profiles.find((p) => p.id === profileId); if (!profile) { throw this.createError("PROFILE_NOT_FOUND", "Profile not found"); } this.state.user.activeProfileId = profileId; this.emit({ type: "PROFILE_SWITCHED", profileId }); }, generateDeviceLink: async () => { if (!this.state.isAuthenticated || !this.state.user) { throw this.createError("AUTH_REQUIRED", "Must be authenticated to generate device link"); } const response = await fetch(`${this.config.apiUrl}/device-link/generate`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ idKey: this.state.user.id }), credentials: "include", }); if (!response.ok) { throw this.createError("API_ERROR", "Failed to generate device link"); } const { token } = await response.json(); return token; }, importFromDeviceLink: async (token) => { const response = await fetch(`${this.config.apiUrl}/device-link/validate`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ token }), }); if (!response.ok) { throw this.createError("INVALID_TOKEN", "Invalid or expired device link"); } const { encryptedBackup } = await response.json(); await this.persistentStorage.set(this.storageKeys.encryptedBackup, encryptedBackup); this.state.currentStep = "password"; }, setStep: (step) => { this.state.currentStep = step; this.emit({ type: "STEP_CHANGED", step }); }, setMode: (mode) => { this.state.mode = mode; this.emit({ type: "MODE_CHANGED", mode }); }, reset: () => { this.state = { user: null, isAuthenticated: false, isLoading: false, error: null, currentStep: "initial", mode: "signin", hasLocalBackup: false, backupStatus: { hasLocal: false, hasCloud: false, cloudProviders: [], lastUpdated: undefined, }, hasWalletCapabilities: false, linkedProviders: [], pendingOAuthProvider: null, }; this.checkExistingBackup(); }, getWalletExtension: this.getWalletExtension.bind(this), hasUnencryptedBackupInSession: this.hasUnencryptedBackupInSession.bind(this), hasEncryptedBackupStored: this.hasEncryptedBackupStored.bind(this), isSessionBackupImported: this.isSessionBackupImported.bind(this), storeGeneratedBackupForSignup: this.storeGeneratedBackupForSignup.bind(this), }; } } export function createAuthManager(config) { return new AuthManager(config); }