bigblocks
Version:
Complete Bitcoin UI component library - authentication, social, wallet, market, inscriptions, and blockchain interactions for React
968 lines (967 loc) • 41.2 kB
JavaScript
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);
}