From ccff136edb61de951ffdfbf7acfd867a80aa211f Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 20 Sep 2025 10:39:43 -0400 Subject: [PATCH] Single Source of Truth Architecture - Complete authentication state management with storage-based getAuthState() as sole authoritative source --- .gitignore | 2 +- README.md | 72 ++-- examples/button.html | 67 +++- examples/embedded.html | 1 + examples/login-and-profile.html | 30 +- examples/modal.html | 1 + examples/session-isolation-test.html | 534 +++++++++++++++++++++++++++ lite/build.js | 303 +++++++++++---- lite/nostr-lite.js | 305 +++++++++++---- login_logic.md | 413 +++++++++++++++++++++ themes/default/theme.css | 2 +- 11 files changed, 1530 insertions(+), 200 deletions(-) create mode 100644 examples/session-isolation-test.html create mode 100644 login_logic.md diff --git a/.gitignore b/.gitignore index 717048a..effd990 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ # Dependencies node_modules/ -nostr-tools/ + # IDE and OS files .idea/ diff --git a/README.md b/README.md index 03d730e..9b9ec8e 100644 --- a/README.md +++ b/README.md @@ -1,51 +1,47 @@ Nostr_Login_Lite =========== -## Floating Tab API +## API -Configure persistent floating tab for login/logout: +Configure for login/logout: ```javascript -await NOSTR_LOGIN_LITE.init({ - // Set the initial theme (default: 'default') - theme: 'dark', // Choose from 'default' or 'dark' - - // Standard configuration options +await window.NOSTR_LOGIN_LITE.init({ + theme: 'default', + methods: { extension: true, local: true, + seedphrase: true, // βœ… Must be explicitly enabled readonly: true, connect: true, - otp: true + otp: false }, - // Floating tab configuration (now uses theme-aware text icons) floatingTab: { enabled: true, - hPosition: 0.95, // 0.0-1.0 or '95%' from left - vPosition: 0.5, // 0.0-1.0 or '50%' from top - getUserInfo: true, // Fetch user profile name from relays - getUserRelay: [ // Relays for profile queries - 'wss://relay.damus.io', - 'wss://nos.lol' - ], + hPosition: 0.95, // Near right edge + vPosition: 0.1, // Near top + appearance: { - style: 'pill', // 'pill', 'square', 'circle', 'minimal' - theme: 'auto', // 'auto' follows main theme - icon: '[LOGIN]', // Now uses text-based icons like [LOGIN], [KEY], [NET] - text: 'Login' + style: 'pill', + icon: '[LOGIN]', + text: 'Sign In', + iconOnly: false }, + behavior: { - hideWhenAuthenticated: false, + hideWhenAuthenticated: false, // Keep visible after login showUserInfo: true, autoSlide: true }, - animation: { - slideDirection: 'auto' // 'auto', 'left', 'right', 'up', 'down' - } + + getUserInfo: true, // βœ… Fetch user profiles + getUserRelay: ['wss://relay.laantungir.net'] // Custom relays for profiles } }); + // After initialization, you can switch themes dynamically: NOSTR_LOGIN_LITE.switchTheme('dark'); NOSTR_LOGIN_LITE.switchTheme('default'); @@ -86,3 +82,31 @@ const modal = NOSTR_LOGIN_LITE.embed('#login-container', { ``` Container can be CSS selector or DOM element. Modal renders inline without backdrop overlay. + +## Logout API + +To log out users and clear authentication state: + +```javascript +// Unified logout method - works for all authentication methods +window.NOSTR_LOGIN_LITE.logout(); +``` + +This will: +- Clear persistent authentication data from localStorage +- Dispatch `nlLogout` event for custom cleanup +- Reset the authentication state across all components + +### Event Handling + +Listen for logout events in your application: + +```javascript +window.addEventListener('nlLogout', () => { + console.log('User logged out'); + // Clear your application's UI state + // Redirect to login page, etc. +}); +``` + +The logout system works consistently across all authentication methods (extension, local keys, NIP-46, etc.) and all UI components (floating tab, modal, embedded). diff --git a/examples/button.html b/examples/button.html index e0da565..d68664b 100644 --- a/examples/button.html +++ b/examples/button.html @@ -35,7 +35,7 @@ } #login-button:hover { - background: #0052a3; + opacity: 0.8; } @@ -49,6 +49,9 @@ diff --git a/examples/embedded.html b/examples/embedded.html index 3a7a87b..7e7c47f 100644 --- a/examples/embedded.html +++ b/examples/embedded.html @@ -41,6 +41,7 @@ methods: { extension: true, local: true, + seedphrase: true, readonly: true, connect: true, remote: true, diff --git a/examples/login-and-profile.html b/examples/login-and-profile.html index 349d656..242c161 100644 --- a/examples/login-and-profile.html +++ b/examples/login-and-profile.html @@ -51,19 +51,20 @@ await window.NOSTR_LOGIN_LITE.init({ theme: 'default', darkMode: false, - relays: [relayUrl, 'wss://relay.damus.io'], methods: { extension: true, local: true, - readonly: true, + seedphrase: true, connect: true, // Enables "Nostr Connect" (NIP-46) remote: true, // Also needed for "Nostr Connect" compatibility otp: true // Enables "DM/OTP" }, floatingTab: { enabled: true, - hPosition: 0.80, // 95% from left - vPosition: 0.01, // 50% from top (center) + hPosition: .98, // 95% from left + vPosition: 0, // 50% from top (center) + getUserInfo: true, // Fetch user profiles + getUserRelay: ['wss://relay.laantungir.net'], // Custom relays for profiles appearance: { style: 'minimal', theme: 'auto', @@ -88,16 +89,17 @@ console.log('SUCCESS', 'NOSTR_LOGIN_LITE initialized successfully'); window.addEventListener('nlMethodSelected', handleAuthEvent); + window.addEventListener('nlLogout', handleLogoutEvent); } catch (error) { console.log('ERROR', `Initialization failed: ${error.message}`); - + } } function handleAuthEvent(event) { - const {pubkey, method, error } = event.detail; + const { pubkey, method, error } = event.detail; console.log('INFO', `Auth event received: method=${method}`); if (method && pubkey) { @@ -108,10 +110,20 @@ } else if (error) { console.log('ERROR', `Authentication error: ${error}`); - + } } + function handleLogoutEvent() { + console.log('INFO', 'Logout event received'); + // Clear local UI state + userPubkey = null; + document.getElementById('profile-name').textContent = ''; + document.getElementById('profile-about').textContent = ''; + document.getElementById('profile-pubkey').textContent = ''; + document.getElementById('profile-picture').src = ''; + } + // Load user profile using nostr-tools pool async function loadUserProfile() { if (!userPubkey) return; @@ -124,7 +136,7 @@ // Create a SimplePool instance const pool = new window.NostrTools.SimplePool(); const relays = [relayUrl, 'wss://relay.laantungir.net']; - + // Get profile event (kind 0) for the user using querySync const events = await pool.querySync(relays, { kinds: [0], @@ -171,7 +183,7 @@ async function logout() { console.log('INFO', 'Logging out...'); try { - await nlLite.logout(); + window.NOSTR_LOGIN_LITE.logout(); console.log('SUCCESS', 'Logged out successfully'); } catch (error) { console.log('ERROR', `Logout failed: ${error.message}`); diff --git a/examples/modal.html b/examples/modal.html index cb01a6e..639f2f2 100644 --- a/examples/modal.html +++ b/examples/modal.html @@ -41,6 +41,7 @@ methods: { extension: true, local: true, + seedphrase:true, readonly: true, connect: true, remote: true, diff --git a/examples/session-isolation-test.html b/examples/session-isolation-test.html new file mode 100644 index 0000000..58cc2e2 --- /dev/null +++ b/examples/session-isolation-test.html @@ -0,0 +1,534 @@ + + + + + + Session Isolation Test - NOSTR LOGIN LITE + + + +
+

πŸ” Session Isolation Test

+ +
+

πŸ“‹ Test Instructions

+
    +
  1. Isolated Session: Each tab/window has independent authentication
  2. +
  3. Login in this tab/window - it will persist on refresh
  4. +
  5. Open new windows/tabs - they will start unauthenticated
  6. +
  7. Login with different users in different windows simultaneously
  8. +
  9. Refresh any window - authentication persists within that window only
  10. +
+
+ +
+ πŸ”’ ISOLATED MODE (sessionStorage) +
+ +
+ 🚨 Session Isolation Active: +

This tab uses sessionStorage - authentication is isolated to this window only. Refreshing will maintain your login state, but other tabs/windows are independent.

+
+ +
+

Authentication Status

+
Not authenticated
+
+
+ +
+

Actions

+ + + + + + +
+ +
+

Storage Inspector

+ + +
+
+ +
+

Test Results

+
+
+
+ + + + + + + \ No newline at end of file diff --git a/lite/build.js b/lite/build.js index 5a9bdea..abbe37d 100644 --- a/lite/build.js +++ b/lite/build.js @@ -191,8 +191,6 @@ class FloatingTab { ...options }; - this.isAuthenticated = false; - this.userInfo = null; this.userProfile = null; this.container = null; this.isVisible = false; @@ -211,6 +209,12 @@ class FloatingTab { this.show(); } + // Get authentication state from authoritative source (Global Storage-Based Function) + _getAuthState() { + return window.NOSTR_LOGIN_LITE?.getAuthState?.() || null; + } + + _createContainer() { // Remove existing floating tab if any const existingTab = document.getElementById('nl-floating-tab'); @@ -286,24 +290,79 @@ class FloatingTab { console.log('πŸ” FloatingTab: Logout event received'); this._handleLogout(); }); + + // Check for existing authentication state on initialization + window.addEventListener('load', () => { + setTimeout(() => { + this._checkExistingAuth(); + }, 1000); // Wait 1 second for all initialization to complete + }); } - async _handleClick() { + // Check for existing authentication on page load + async _checkExistingAuth() { + console.log('πŸ” FloatingTab: === _checkExistingAuth START ==='); + + try { + const storageKey = 'nostr_login_lite_auth'; + let storedAuth = null; + + // Try sessionStorage first, then localStorage + if (sessionStorage.getItem(storageKey)) { + storedAuth = JSON.parse(sessionStorage.getItem(storageKey)); + console.log('πŸ” FloatingTab: Found auth in sessionStorage:', storedAuth.method); + } else if (localStorage.getItem(storageKey)) { + storedAuth = JSON.parse(localStorage.getItem(storageKey)); + console.log('πŸ” FloatingTab: Found auth in localStorage:', storedAuth.method); + } + + if (storedAuth) { + // Check if stored auth is not expired + const maxAge = storedAuth.method === 'extension' ? 60 * 60 * 1000 : 24 * 60 * 60 * 1000; + if (Date.now() - storedAuth.timestamp <= maxAge) { + console.log('πŸ” FloatingTab: Found valid stored auth, simulating auth event'); + + // Create auth data object for FloatingTab + const authData = { + method: storedAuth.method, + pubkey: storedAuth.pubkey + }; + + // For extensions, try to find the extension + if (storedAuth.method === 'extension') { + if (window.nostr && window.nostr.constructor?.name !== 'WindowNostr') { + authData.extension = window.nostr; + } + } + + await this._handleAuth(authData); + } else { + console.log('πŸ” FloatingTab: Stored auth expired, clearing'); + sessionStorage.removeItem(storageKey); + localStorage.removeItem(storageKey); + } + } else { + console.log('πŸ” FloatingTab: No existing authentication found'); + } + + } catch (error) { + console.error('πŸ” FloatingTab: Error checking existing auth:', error); + } + + console.log('πŸ” FloatingTab: === _checkExistingAuth END ==='); + } + + _handleClick() { console.log('FloatingTab: Clicked'); - if (this.isAuthenticated && this.options.behavior.showUserInfo) { + const authState = this._getAuthState(); + if (authState && this.options.behavior.showUserInfo) { // Show user menu or profile options this._showUserMenu(); } else { - // Check if extension is available for direct login - if (window.nostr && this._isRealExtension(window.nostr)) { - console.log('FloatingTab: Extension available, attempting direct extension login'); - await this._tryExtensionLogin(window.nostr); - } else { - // Open login modal - if (this.modal) { - this.modal.open({ startScreen: 'login' }); - } + // Always open login modal (consistent with login buttons) + if (this.modal) { + this.modal.open({ startScreen: 'login' }); } } } @@ -385,46 +444,56 @@ class FloatingTab { async _handleAuth(authData) { console.log('πŸ” FloatingTab: === _handleAuth START ==='); console.log('πŸ” FloatingTab: authData received:', authData); - console.log('πŸ” FloatingTab: Current isAuthenticated before:', this.isAuthenticated); - this.isAuthenticated = true; - this.userInfo = authData; - - console.log('πŸ” FloatingTab: Set isAuthenticated to true'); - console.log('πŸ” FloatingTab: Set userInfo to:', this.userInfo); - - // Fetch user profile if enabled and we have a pubkey - if (this.options.getUserInfo && authData.pubkey) { - console.log('πŸ” FloatingTab: getUserInfo enabled, fetching profile for:', authData.pubkey); - try { - const profile = await this._fetchUserProfile(authData.pubkey); - this.userProfile = profile; - console.log('πŸ” FloatingTab: User profile fetched:', profile); - } catch (error) { - console.warn('πŸ” FloatingTab: Failed to fetch user profile:', error); - this.userProfile = null; + // Wait a brief moment for WindowNostr to process the authentication + setTimeout(async () => { + console.log('πŸ” FloatingTab: Checking authentication state from authoritative source...'); + + const authState = this._getAuthState(); + const isAuthenticated = !!authState; + + console.log('πŸ” FloatingTab: Authoritative auth state:', authState); + console.log('πŸ” FloatingTab: Is authenticated:', isAuthenticated); + + if (isAuthenticated) { + console.log('πŸ” FloatingTab: βœ… Authentication verified from authoritative source'); + } else { + console.error('πŸ” FloatingTab: ❌ Authentication not found in authoritative source'); } - } else { - console.log('πŸ” FloatingTab: getUserInfo disabled or no pubkey, skipping profile fetch'); - } - - console.log('πŸ” FloatingTab: hideWhenAuthenticated option:', this.options.behavior.hideWhenAuthenticated); - - if (this.options.behavior.hideWhenAuthenticated) { - console.log('πŸ” FloatingTab: Hiding tab (hideWhenAuthenticated=true)'); - this.hide(); - } else { - console.log('πŸ” FloatingTab: Updating appearance (hideWhenAuthenticated=false)'); - this._updateAppearance(); - } + + // Fetch user profile if enabled and we have a pubkey + if (this.options.getUserInfo && authData.pubkey) { + console.log('πŸ” FloatingTab: getUserInfo enabled, fetching profile for:', authData.pubkey); + try { + const profile = await this._fetchUserProfile(authData.pubkey); + this.userProfile = profile; + console.log('πŸ” FloatingTab: User profile fetched:', profile); + } catch (error) { + console.warn('πŸ” FloatingTab: Failed to fetch user profile:', error); + this.userProfile = null; + } + } else { + console.log('πŸ” FloatingTab: getUserInfo disabled or no pubkey, skipping profile fetch'); + } + + this._updateAppearance(); // Update UI based on authoritative state + + console.log('πŸ” FloatingTab: hideWhenAuthenticated option:', this.options.behavior.hideWhenAuthenticated); + + if (this.options.behavior.hideWhenAuthenticated && isAuthenticated) { + console.log('πŸ” FloatingTab: Hiding tab (hideWhenAuthenticated=true and authenticated)'); + this.hide(); + } else { + console.log('πŸ” FloatingTab: Keeping tab visible'); + } + + }, 500); // Wait 500ms for WindowNostr to complete authentication processing console.log('πŸ” FloatingTab: === _handleAuth END ==='); } _handleLogout() { console.log('FloatingTab: Handling logout'); - this.isAuthenticated = false; - this.userInfo = null; this.userProfile = null; if (this.options.behavior.hideWhenAuthenticated) { @@ -491,8 +560,12 @@ class FloatingTab { _updateAppearance() { if (!this.container) return; + // Query authoritative source for all state information + const authState = this._getAuthState(); + const isAuthenticated = authState !== null; + // Update content - if (this.isAuthenticated && this.options.behavior.showUserInfo) { + if (isAuthenticated && this.options.behavior.showUserInfo) { let display; // Use profile name if available, otherwise fall back to pubkey @@ -501,11 +574,11 @@ class FloatingTab { display = this.options.appearance.iconOnly ? userName.slice(0, 8) : userName; - } else if (this.userInfo?.pubkey) { + } else if (authState?.pubkey) { // Fallback to pubkey display display = this.options.appearance.iconOnly - ? this.userInfo.pubkey.slice(0, 6) - : \`\${this.userInfo.pubkey.slice(0, 6)}...\`; + ? authState.pubkey.slice(0, 6) + : \`\${authState.pubkey.slice(0, 6)}...\`; } else { display = this.options.appearance.iconOnly ? 'User' : 'Authenticated'; } @@ -688,10 +761,11 @@ class FloatingTab { // Get current state getState() { + const authState = this._getAuthState(); return { isVisible: this.isVisible, - isAuthenticated: this.isAuthenticated, - userInfo: this.userInfo, + isAuthenticated: !!authState, + userInfo: authState, options: this.options }; } @@ -766,6 +840,7 @@ class NostrLite { this.options = { theme: 'default', persistence: true, // Enable persistent authentication by default + isolateSession: false, // Use localStorage by default for cross-window persistence methods: { extension: true, local: true, @@ -870,7 +945,7 @@ class NostrLite { console.log('πŸ” NOSTR_LOGIN_LITE: window.nostr before installation:', window.nostr); console.log('πŸ” NOSTR_LOGIN_LITE: window.nostr constructor before:', window.nostr?.constructor?.name); - const facade = new WindowNostr(this, existingNostr); + const facade = new WindowNostr(this, existingNostr, { isolateSession: this.options.isolateSession }); window.nostr = facade; this.facadeInstalled = true; @@ -1008,7 +1083,7 @@ class NostrLite { console.log('πŸ” NOSTR_LOGIN_LITE: === _attemptExtensionRestore START ==='); // Use a simple AuthManager instance for extension persistence - const authManager = new AuthManager(); + const authManager = new AuthManager({ isolateSession: this.options?.isolateSession }); const storedAuth = await authManager.restoreAuthState(); if (!storedAuth || storedAuth.method !== 'extension') { @@ -1291,9 +1366,18 @@ class CryptoUtils { // Unified authentication state manager class AuthManager { - constructor() { + constructor(options = {}) { this.storageKey = 'nostr_login_lite_auth'; this.currentAuthState = null; + + // Configure storage type based on isolateSession option + if (options.isolateSession) { + this.storage = sessionStorage; + console.log('AuthManager: Using sessionStorage for per-window isolation'); + } else { + this.storage = localStorage; + console.log('AuthManager: Using localStorage for cross-window persistence'); + } } // Save authentication state with method-specific security @@ -1353,7 +1437,7 @@ class AuthManager { throw new Error(\`Unknown auth method: \${authData.method}\`); } - localStorage.setItem(this.storageKey, JSON.stringify(authState)); + this.storage.setItem(this.storageKey, JSON.stringify(authState)); this.currentAuthState = authState; console.log('AuthManager: Auth state saved for method:', authData.method); @@ -1369,7 +1453,7 @@ class AuthManager { console.log('πŸ” AuthManager: === restoreAuthState START ==='); console.log('πŸ” AuthManager: storageKey:', this.storageKey); - const stored = localStorage.getItem(this.storageKey); + const stored = this.storage.getItem(this.storageKey); console.log('πŸ” AuthManager: localStorage raw value:', stored); if (!stored) { @@ -1656,7 +1740,7 @@ class AuthManager { // Clear stored authentication state clearAuthState() { - localStorage.removeItem(this.storageKey); + this.storage.removeItem(this.storageKey); sessionStorage.removeItem('nostr_session_key'); this.currentAuthState = null; console.log('AuthManager: Auth state cleared'); @@ -1671,14 +1755,14 @@ class AuthManager { // Check if we have valid stored auth hasStoredAuth() { - const stored = localStorage.getItem(this.storageKey); + const stored = this.storage.getItem(this.storageKey); return !!stored; } // Get current auth method without full restoration getStoredAuthMethod() { try { - const stored = localStorage.getItem(this.storageKey); + const stored = this.storage.getItem(this.storageKey); if (!stored) return null; const authState = JSON.parse(stored); @@ -1696,7 +1780,7 @@ class WindowNostr { this.authState = null; this.existingNostr = existingNostr; this.authenticatedExtension = null; - this.authManager = new AuthManager(); + this.authManager = new AuthManager({ isolateSession: nostrLite.options?.isolateSession }); this._setupEventListeners(); } @@ -1974,17 +2058,18 @@ class WindowNostr { get nip44() { return { encrypt: async (pubkey, plaintext) => { - if (!this.authState) { + const authState = getAuthState(); + if (!authState) { throw new Error('Not authenticated - use NOSTR_LOGIN_LITE.launch()'); } - if (this.authState.method === 'readonly') { + if (authState.method === 'readonly') { throw new Error('Read-only mode - cannot encrypt'); } - switch (this.authState.method) { + switch (authState.method) { case 'extension': { - const ext = this.authenticatedExtension || this.authState.extension || this.existingNostr; + const ext = this.authenticatedExtension || authState.extension || this.existingNostr; if (!ext) throw new Error('Extension not available'); return await ext.nip44.encrypt(pubkey, plaintext); } @@ -1993,40 +2078,41 @@ class WindowNostr { const { nip44, nip19 } = window.NostrTools; let secretKey; - if (this.authState.secret.startsWith('nsec')) { - const decoded = nip19.decode(this.authState.secret); + if (authState.secret.startsWith('nsec')) { + const decoded = nip19.decode(authState.secret); secretKey = decoded.data; } else { - secretKey = this._hexToUint8Array(this.authState.secret); + secretKey = this._hexToUint8Array(authState.secret); } return nip44.encrypt(plaintext, nip44.getConversationKey(secretKey, pubkey)); } case 'nip46': { - if (!this.authState.signer?.bunkerSigner) { + if (!authState.signer?.bunkerSigner) { throw new Error('NIP-46 signer not available'); } - return await this.authState.signer.bunkerSigner.nip44Encrypt(pubkey, plaintext); + return await authState.signer.bunkerSigner.nip44Encrypt(pubkey, plaintext); } default: - throw new Error(\`Unsupported auth method: \${this.authState.method}\`); + throw new Error('Unsupported auth method: ' + authState.method); } }, decrypt: async (pubkey, ciphertext) => { - if (!this.authState) { + const authState = getAuthState(); + if (!authState) { throw new Error('Not authenticated - use NOSTR_LOGIN_LITE.launch()'); } - if (this.authState.method === 'readonly') { + if (authState.method === 'readonly') { throw new Error('Read-only mode - cannot decrypt'); } - switch (this.authState.method) { + switch (authState.method) { case 'extension': { - const ext = this.authenticatedExtension || this.authState.extension || this.existingNostr; + const ext = this.authenticatedExtension || authState.extension || this.existingNostr; if (!ext) throw new Error('Extension not available'); return await ext.nip44.decrypt(pubkey, ciphertext); } @@ -2035,25 +2121,25 @@ class WindowNostr { const { nip44, nip19 } = window.NostrTools; let secretKey; - if (this.authState.secret.startsWith('nsec')) { - const decoded = nip19.decode(this.authState.secret); + if (authState.secret.startsWith('nsec')) { + const decoded = nip19.decode(authState.secret); secretKey = decoded.data; } else { - secretKey = this._hexToUint8Array(this.authState.secret); + secretKey = this._hexToUint8Array(authState.secret); } return nip44.decrypt(ciphertext, nip44.getConversationKey(secretKey, pubkey)); } case 'nip46': { - if (!this.authState.signer?.bunkerSigner) { + if (!authState.signer?.bunkerSigner) { throw new Error('NIP-46 signer not available'); } - return await this.authState.signer.bunkerSigner.nip44Decrypt(pubkey, ciphertext); + return await authState.signer.bunkerSigner.nip44Decrypt(pubkey, ciphertext); } default: - throw new Error(\`Unsupported auth method: \${this.authState.method}\`); + throw new Error('Unsupported auth method: ' + authState.method); } } }; @@ -2071,6 +2157,60 @@ class WindowNostr { } } +// ====================================== +// Global Authentication State Manager - Single Source of Truth +// ====================================== + +// Storage-based authentication state - works regardless of extension presence +function getAuthState() { + try { + console.log('πŸ” getAuthState: === GLOBAL AUTH STATE CHECK ==='); + + const storageKey = 'nostr_login_lite_auth'; + let stored = null; + let storageType = null; + + // Check sessionStorage first (per-window isolation), then localStorage + if (sessionStorage.getItem(storageKey)) { + stored = sessionStorage.getItem(storageKey); + storageType = 'sessionStorage'; + console.log('πŸ” getAuthState: Found auth in sessionStorage'); + } else if (localStorage.getItem(storageKey)) { + stored = localStorage.getItem(storageKey); + storageType = 'localStorage'; + console.log('πŸ” getAuthState: Found auth in localStorage'); + } + + if (!stored) { + console.log('πŸ” getAuthState: ❌ No stored auth state found'); + return null; + } + + const authState = JSON.parse(stored); + console.log('πŸ” getAuthState: βœ… Parsed stored auth state from', storageType); + console.log('πŸ” getAuthState: Method:', authState.method); + console.log('πŸ” getAuthState: Pubkey:', authState.pubkey); + console.log('πŸ” getAuthState: Age (ms):', Date.now() - authState.timestamp); + + // Check if auth state is expired + const maxAge = authState.method === 'extension' ? 60 * 60 * 1000 : 24 * 60 * 60 * 1000; + if (Date.now() - authState.timestamp > maxAge) { + console.log('πŸ” getAuthState: ❌ Auth state expired, clearing'); + sessionStorage.removeItem(storageKey); + localStorage.removeItem(storageKey); + return null; + } + + console.log('πŸ” getAuthState: βœ… Valid auth state found'); + return authState; + + } catch (error) { + console.error('πŸ” getAuthState: ❌ Error reading auth state:', error); + return null; + } +} + + // Initialize and export if (typeof window !== 'undefined') { const nostrLite = new NostrLite(); @@ -2096,6 +2236,9 @@ if (typeof window !== 'undefined') { updateFloatingTab: (options) => nostrLite.updateFloatingTab(options), getFloatingTabState: () => nostrLite.getFloatingTabState(), + // GLOBAL AUTHENTICATION STATE API - Single Source of Truth + getAuthState: getAuthState, + // Expose for debugging _extensionBridge: nostrLite.extensionBridge, _instance: nostrLite diff --git a/lite/nostr-lite.js b/lite/nostr-lite.js index 3436be7..f6696d3 100644 --- a/lite/nostr-lite.js +++ b/lite/nostr-lite.js @@ -8,7 +8,7 @@ * Two-file architecture: * 1. Load nostr.bundle.js (official nostr-tools bundle) * 2. Load nostr-lite.js (this file - NOSTR_LOGIN_LITE library with CSS-only themes) - * Generated on: 2025-09-19T19:39:40.411Z + * Generated on: 2025-09-20T14:23:53.897Z */ // Verify dependencies are loaded @@ -2158,8 +2158,6 @@ class FloatingTab { ...options }; - this.isAuthenticated = false; - this.userInfo = null; this.userProfile = null; this.container = null; this.isVisible = false; @@ -2178,6 +2176,12 @@ class FloatingTab { this.show(); } + // Get authentication state from authoritative source (Global Storage-Based Function) + _getAuthState() { + return window.NOSTR_LOGIN_LITE?.getAuthState?.() || null; + } + + _createContainer() { // Remove existing floating tab if any const existingTab = document.getElementById('nl-floating-tab'); @@ -2253,24 +2257,79 @@ class FloatingTab { console.log('πŸ” FloatingTab: Logout event received'); this._handleLogout(); }); + + // Check for existing authentication state on initialization + window.addEventListener('load', () => { + setTimeout(() => { + this._checkExistingAuth(); + }, 1000); // Wait 1 second for all initialization to complete + }); } - async _handleClick() { + // Check for existing authentication on page load + async _checkExistingAuth() { + console.log('πŸ” FloatingTab: === _checkExistingAuth START ==='); + + try { + const storageKey = 'nostr_login_lite_auth'; + let storedAuth = null; + + // Try sessionStorage first, then localStorage + if (sessionStorage.getItem(storageKey)) { + storedAuth = JSON.parse(sessionStorage.getItem(storageKey)); + console.log('πŸ” FloatingTab: Found auth in sessionStorage:', storedAuth.method); + } else if (localStorage.getItem(storageKey)) { + storedAuth = JSON.parse(localStorage.getItem(storageKey)); + console.log('πŸ” FloatingTab: Found auth in localStorage:', storedAuth.method); + } + + if (storedAuth) { + // Check if stored auth is not expired + const maxAge = storedAuth.method === 'extension' ? 60 * 60 * 1000 : 24 * 60 * 60 * 1000; + if (Date.now() - storedAuth.timestamp <= maxAge) { + console.log('πŸ” FloatingTab: Found valid stored auth, simulating auth event'); + + // Create auth data object for FloatingTab + const authData = { + method: storedAuth.method, + pubkey: storedAuth.pubkey + }; + + // For extensions, try to find the extension + if (storedAuth.method === 'extension') { + if (window.nostr && window.nostr.constructor?.name !== 'WindowNostr') { + authData.extension = window.nostr; + } + } + + await this._handleAuth(authData); + } else { + console.log('πŸ” FloatingTab: Stored auth expired, clearing'); + sessionStorage.removeItem(storageKey); + localStorage.removeItem(storageKey); + } + } else { + console.log('πŸ” FloatingTab: No existing authentication found'); + } + + } catch (error) { + console.error('πŸ” FloatingTab: Error checking existing auth:', error); + } + + console.log('πŸ” FloatingTab: === _checkExistingAuth END ==='); + } + + _handleClick() { console.log('FloatingTab: Clicked'); - if (this.isAuthenticated && this.options.behavior.showUserInfo) { + const authState = this._getAuthState(); + if (authState && this.options.behavior.showUserInfo) { // Show user menu or profile options this._showUserMenu(); } else { - // Check if extension is available for direct login - if (window.nostr && this._isRealExtension(window.nostr)) { - console.log('FloatingTab: Extension available, attempting direct extension login'); - await this._tryExtensionLogin(window.nostr); - } else { - // Open login modal - if (this.modal) { - this.modal.open({ startScreen: 'login' }); - } + // Always open login modal (consistent with login buttons) + if (this.modal) { + this.modal.open({ startScreen: 'login' }); } } } @@ -2352,46 +2411,56 @@ class FloatingTab { async _handleAuth(authData) { console.log('πŸ” FloatingTab: === _handleAuth START ==='); console.log('πŸ” FloatingTab: authData received:', authData); - console.log('πŸ” FloatingTab: Current isAuthenticated before:', this.isAuthenticated); - this.isAuthenticated = true; - this.userInfo = authData; - - console.log('πŸ” FloatingTab: Set isAuthenticated to true'); - console.log('πŸ” FloatingTab: Set userInfo to:', this.userInfo); - - // Fetch user profile if enabled and we have a pubkey - if (this.options.getUserInfo && authData.pubkey) { - console.log('πŸ” FloatingTab: getUserInfo enabled, fetching profile for:', authData.pubkey); - try { - const profile = await this._fetchUserProfile(authData.pubkey); - this.userProfile = profile; - console.log('πŸ” FloatingTab: User profile fetched:', profile); - } catch (error) { - console.warn('πŸ” FloatingTab: Failed to fetch user profile:', error); - this.userProfile = null; + // Wait a brief moment for WindowNostr to process the authentication + setTimeout(async () => { + console.log('πŸ” FloatingTab: Checking authentication state from authoritative source...'); + + const authState = this._getAuthState(); + const isAuthenticated = !!authState; + + console.log('πŸ” FloatingTab: Authoritative auth state:', authState); + console.log('πŸ” FloatingTab: Is authenticated:', isAuthenticated); + + if (isAuthenticated) { + console.log('πŸ” FloatingTab: βœ… Authentication verified from authoritative source'); + } else { + console.error('πŸ” FloatingTab: ❌ Authentication not found in authoritative source'); } - } else { - console.log('πŸ” FloatingTab: getUserInfo disabled or no pubkey, skipping profile fetch'); - } - - console.log('πŸ” FloatingTab: hideWhenAuthenticated option:', this.options.behavior.hideWhenAuthenticated); - - if (this.options.behavior.hideWhenAuthenticated) { - console.log('πŸ” FloatingTab: Hiding tab (hideWhenAuthenticated=true)'); - this.hide(); - } else { - console.log('πŸ” FloatingTab: Updating appearance (hideWhenAuthenticated=false)'); - this._updateAppearance(); - } + + // Fetch user profile if enabled and we have a pubkey + if (this.options.getUserInfo && authData.pubkey) { + console.log('πŸ” FloatingTab: getUserInfo enabled, fetching profile for:', authData.pubkey); + try { + const profile = await this._fetchUserProfile(authData.pubkey); + this.userProfile = profile; + console.log('πŸ” FloatingTab: User profile fetched:', profile); + } catch (error) { + console.warn('πŸ” FloatingTab: Failed to fetch user profile:', error); + this.userProfile = null; + } + } else { + console.log('πŸ” FloatingTab: getUserInfo disabled or no pubkey, skipping profile fetch'); + } + + this._updateAppearance(); // Update UI based on authoritative state + + console.log('πŸ” FloatingTab: hideWhenAuthenticated option:', this.options.behavior.hideWhenAuthenticated); + + if (this.options.behavior.hideWhenAuthenticated && isAuthenticated) { + console.log('πŸ” FloatingTab: Hiding tab (hideWhenAuthenticated=true and authenticated)'); + this.hide(); + } else { + console.log('πŸ” FloatingTab: Keeping tab visible'); + } + + }, 500); // Wait 500ms for WindowNostr to complete authentication processing console.log('πŸ” FloatingTab: === _handleAuth END ==='); } _handleLogout() { console.log('FloatingTab: Handling logout'); - this.isAuthenticated = false; - this.userInfo = null; this.userProfile = null; if (this.options.behavior.hideWhenAuthenticated) { @@ -2458,8 +2527,12 @@ class FloatingTab { _updateAppearance() { if (!this.container) return; + // Query authoritative source for all state information + const authState = this._getAuthState(); + const isAuthenticated = authState !== null; + // Update content - if (this.isAuthenticated && this.options.behavior.showUserInfo) { + if (isAuthenticated && this.options.behavior.showUserInfo) { let display; // Use profile name if available, otherwise fall back to pubkey @@ -2468,11 +2541,11 @@ class FloatingTab { display = this.options.appearance.iconOnly ? userName.slice(0, 8) : userName; - } else if (this.userInfo?.pubkey) { + } else if (authState?.pubkey) { // Fallback to pubkey display display = this.options.appearance.iconOnly - ? this.userInfo.pubkey.slice(0, 6) - : `${this.userInfo.pubkey.slice(0, 6)}...`; + ? authState.pubkey.slice(0, 6) + : `${authState.pubkey.slice(0, 6)}...`; } else { display = this.options.appearance.iconOnly ? 'User' : 'Authenticated'; } @@ -2655,10 +2728,11 @@ class FloatingTab { // Get current state getState() { + const authState = this._getAuthState(); return { isVisible: this.isVisible, - isAuthenticated: this.isAuthenticated, - userInfo: this.userInfo, + isAuthenticated: !!authState, + userInfo: authState, options: this.options }; } @@ -2733,6 +2807,7 @@ class NostrLite { this.options = { theme: 'default', persistence: true, // Enable persistent authentication by default + isolateSession: false, // Use localStorage by default for cross-window persistence methods: { extension: true, local: true, @@ -2837,7 +2912,7 @@ class NostrLite { console.log('πŸ” NOSTR_LOGIN_LITE: window.nostr before installation:', window.nostr); console.log('πŸ” NOSTR_LOGIN_LITE: window.nostr constructor before:', window.nostr?.constructor?.name); - const facade = new WindowNostr(this, existingNostr); + const facade = new WindowNostr(this, existingNostr, { isolateSession: this.options.isolateSession }); window.nostr = facade; this.facadeInstalled = true; @@ -2975,7 +3050,7 @@ class NostrLite { console.log('πŸ” NOSTR_LOGIN_LITE: === _attemptExtensionRestore START ==='); // Use a simple AuthManager instance for extension persistence - const authManager = new AuthManager(); + const authManager = new AuthManager({ isolateSession: this.options?.isolateSession }); const storedAuth = await authManager.restoreAuthState(); if (!storedAuth || storedAuth.method !== 'extension') { @@ -3258,9 +3333,18 @@ class CryptoUtils { // Unified authentication state manager class AuthManager { - constructor() { + constructor(options = {}) { this.storageKey = 'nostr_login_lite_auth'; this.currentAuthState = null; + + // Configure storage type based on isolateSession option + if (options.isolateSession) { + this.storage = sessionStorage; + console.log('AuthManager: Using sessionStorage for per-window isolation'); + } else { + this.storage = localStorage; + console.log('AuthManager: Using localStorage for cross-window persistence'); + } } // Save authentication state with method-specific security @@ -3320,7 +3404,7 @@ class AuthManager { throw new Error(`Unknown auth method: ${authData.method}`); } - localStorage.setItem(this.storageKey, JSON.stringify(authState)); + this.storage.setItem(this.storageKey, JSON.stringify(authState)); this.currentAuthState = authState; console.log('AuthManager: Auth state saved for method:', authData.method); @@ -3336,7 +3420,7 @@ class AuthManager { console.log('πŸ” AuthManager: === restoreAuthState START ==='); console.log('πŸ” AuthManager: storageKey:', this.storageKey); - const stored = localStorage.getItem(this.storageKey); + const stored = this.storage.getItem(this.storageKey); console.log('πŸ” AuthManager: localStorage raw value:', stored); if (!stored) { @@ -3623,7 +3707,7 @@ class AuthManager { // Clear stored authentication state clearAuthState() { - localStorage.removeItem(this.storageKey); + this.storage.removeItem(this.storageKey); sessionStorage.removeItem('nostr_session_key'); this.currentAuthState = null; console.log('AuthManager: Auth state cleared'); @@ -3638,14 +3722,14 @@ class AuthManager { // Check if we have valid stored auth hasStoredAuth() { - const stored = localStorage.getItem(this.storageKey); + const stored = this.storage.getItem(this.storageKey); return !!stored; } // Get current auth method without full restoration getStoredAuthMethod() { try { - const stored = localStorage.getItem(this.storageKey); + const stored = this.storage.getItem(this.storageKey); if (!stored) return null; const authState = JSON.parse(stored); @@ -3663,7 +3747,7 @@ class WindowNostr { this.authState = null; this.existingNostr = existingNostr; this.authenticatedExtension = null; - this.authManager = new AuthManager(); + this.authManager = new AuthManager({ isolateSession: nostrLite.options?.isolateSession }); this._setupEventListeners(); } @@ -3941,17 +4025,18 @@ class WindowNostr { get nip44() { return { encrypt: async (pubkey, plaintext) => { - if (!this.authState) { + const authState = getAuthState(); + if (!authState) { throw new Error('Not authenticated - use NOSTR_LOGIN_LITE.launch()'); } - if (this.authState.method === 'readonly') { + if (authState.method === 'readonly') { throw new Error('Read-only mode - cannot encrypt'); } - switch (this.authState.method) { + switch (authState.method) { case 'extension': { - const ext = this.authenticatedExtension || this.authState.extension || this.existingNostr; + const ext = this.authenticatedExtension || authState.extension || this.existingNostr; if (!ext) throw new Error('Extension not available'); return await ext.nip44.encrypt(pubkey, plaintext); } @@ -3960,40 +4045,41 @@ class WindowNostr { const { nip44, nip19 } = window.NostrTools; let secretKey; - if (this.authState.secret.startsWith('nsec')) { - const decoded = nip19.decode(this.authState.secret); + if (authState.secret.startsWith('nsec')) { + const decoded = nip19.decode(authState.secret); secretKey = decoded.data; } else { - secretKey = this._hexToUint8Array(this.authState.secret); + secretKey = this._hexToUint8Array(authState.secret); } return nip44.encrypt(plaintext, nip44.getConversationKey(secretKey, pubkey)); } case 'nip46': { - if (!this.authState.signer?.bunkerSigner) { + if (!authState.signer?.bunkerSigner) { throw new Error('NIP-46 signer not available'); } - return await this.authState.signer.bunkerSigner.nip44Encrypt(pubkey, plaintext); + return await authState.signer.bunkerSigner.nip44Encrypt(pubkey, plaintext); } default: - throw new Error(`Unsupported auth method: ${this.authState.method}`); + throw new Error('Unsupported auth method: ' + authState.method); } }, decrypt: async (pubkey, ciphertext) => { - if (!this.authState) { + const authState = getAuthState(); + if (!authState) { throw new Error('Not authenticated - use NOSTR_LOGIN_LITE.launch()'); } - if (this.authState.method === 'readonly') { + if (authState.method === 'readonly') { throw new Error('Read-only mode - cannot decrypt'); } - switch (this.authState.method) { + switch (authState.method) { case 'extension': { - const ext = this.authenticatedExtension || this.authState.extension || this.existingNostr; + const ext = this.authenticatedExtension || authState.extension || this.existingNostr; if (!ext) throw new Error('Extension not available'); return await ext.nip44.decrypt(pubkey, ciphertext); } @@ -4002,25 +4088,25 @@ class WindowNostr { const { nip44, nip19 } = window.NostrTools; let secretKey; - if (this.authState.secret.startsWith('nsec')) { - const decoded = nip19.decode(this.authState.secret); + if (authState.secret.startsWith('nsec')) { + const decoded = nip19.decode(authState.secret); secretKey = decoded.data; } else { - secretKey = this._hexToUint8Array(this.authState.secret); + secretKey = this._hexToUint8Array(authState.secret); } return nip44.decrypt(ciphertext, nip44.getConversationKey(secretKey, pubkey)); } case 'nip46': { - if (!this.authState.signer?.bunkerSigner) { + if (!authState.signer?.bunkerSigner) { throw new Error('NIP-46 signer not available'); } - return await this.authState.signer.bunkerSigner.nip44Decrypt(pubkey, ciphertext); + return await authState.signer.bunkerSigner.nip44Decrypt(pubkey, ciphertext); } default: - throw new Error(`Unsupported auth method: ${this.authState.method}`); + throw new Error('Unsupported auth method: ' + authState.method); } } }; @@ -4038,6 +4124,60 @@ class WindowNostr { } } +// ====================================== +// Global Authentication State Manager - Single Source of Truth +// ====================================== + +// Storage-based authentication state - works regardless of extension presence +function getAuthState() { + try { + console.log('πŸ” getAuthState: === GLOBAL AUTH STATE CHECK ==='); + + const storageKey = 'nostr_login_lite_auth'; + let stored = null; + let storageType = null; + + // Check sessionStorage first (per-window isolation), then localStorage + if (sessionStorage.getItem(storageKey)) { + stored = sessionStorage.getItem(storageKey); + storageType = 'sessionStorage'; + console.log('πŸ” getAuthState: Found auth in sessionStorage'); + } else if (localStorage.getItem(storageKey)) { + stored = localStorage.getItem(storageKey); + storageType = 'localStorage'; + console.log('πŸ” getAuthState: Found auth in localStorage'); + } + + if (!stored) { + console.log('πŸ” getAuthState: ❌ No stored auth state found'); + return null; + } + + const authState = JSON.parse(stored); + console.log('πŸ” getAuthState: βœ… Parsed stored auth state from', storageType); + console.log('πŸ” getAuthState: Method:', authState.method); + console.log('πŸ” getAuthState: Pubkey:', authState.pubkey); + console.log('πŸ” getAuthState: Age (ms):', Date.now() - authState.timestamp); + + // Check if auth state is expired + const maxAge = authState.method === 'extension' ? 60 * 60 * 1000 : 24 * 60 * 60 * 1000; + if (Date.now() - authState.timestamp > maxAge) { + console.log('πŸ” getAuthState: ❌ Auth state expired, clearing'); + sessionStorage.removeItem(storageKey); + localStorage.removeItem(storageKey); + return null; + } + + console.log('πŸ” getAuthState: βœ… Valid auth state found'); + return authState; + + } catch (error) { + console.error('πŸ” getAuthState: ❌ Error reading auth state:', error); + return null; + } +} + + // Initialize and export if (typeof window !== 'undefined') { const nostrLite = new NostrLite(); @@ -4063,6 +4203,9 @@ if (typeof window !== 'undefined') { updateFloatingTab: (options) => nostrLite.updateFloatingTab(options), getFloatingTabState: () => nostrLite.getFloatingTabState(), + // GLOBAL AUTHENTICATION STATE API - Single Source of Truth + getAuthState: getAuthState, + // Expose for debugging _extensionBridge: nostrLite.extensionBridge, _instance: nostrLite diff --git a/login_logic.md b/login_logic.md new file mode 100644 index 0000000..17da1b2 --- /dev/null +++ b/login_logic.md @@ -0,0 +1,413 @@ +# NOSTR_LOGIN_LITE - Login Logic Analysis + +This document explains the complete login and authentication flow for the NOSTR_LOGIN_LITE library, including how state is maintained upon page refresh. + +## System Architecture Overview + +The library uses a **modular authentication architecture** with these key components: + +1. **FloatingTab** - UI component for login trigger and status display +2. **Modal** - UI component for authentication method selection +3. **NostrLite** - Main library coordinator and facade manager +4. **WindowNostr** - NIP-07 compliant facade for non-extension methods +5. **AuthManager** - Persistent state management with encryption +6. **Extension Bridge** - Browser extension detection and management + +## Authentication Flow Diagrams + +### Initial Page Load Flow + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Page Loads β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ NOSTR_LOGIN_LITE β”‚ +β”‚ .init() called β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” YES β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Real extension │──────────▢│ Extension-First β”‚ +β”‚ detected? β”‚ β”‚ Mode: Don't install β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ facade β”‚ + β”‚ NO β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Install WindowNostr β”‚ +β”‚ facade for local/ β”‚ +β”‚ NIP-46/readonly β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” YES β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Persistence │──────────▢│ _attemptAuthRestore β”‚ +β”‚ enabled? β”‚ β”‚ called β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ NO β”‚ + β–Ό β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Initialization β”‚ β”‚ Check storage for β”‚ +β”‚ complete β”‚ β”‚ saved auth state β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” YES + β”‚ Valid auth state │────────┐ + β”‚ found? β”‚ β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ + β”‚ NO β”‚ + β–Ό β–Ό + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ Show login UI β”‚ β”‚ Restore auth & β”‚ + β”‚ (FloatingTab,etc) β”‚ β”‚ dispatch events β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +### User-Initiated Login Flow + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ User clicks β”‚ β”‚ User clicks β”‚ +β”‚ FloatingTab β”‚ β”‚ Login Button β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ β”‚ + β–Ό β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ Extension β”‚ β”‚ +β”‚ available? β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ + β”‚ YES β”‚ + β–Ό β”‚ +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ Auto-try extension β”‚ β”‚ +β”‚ authentication β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ + β”‚ SUCCESS β”‚ + β–Ό β”‚ +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ Authentication β”‚ β”‚ +β”‚ complete β”‚β—€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ FAIL OR ALWAYS + β–Ό + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ Open Modal with β”‚ + β”‚ method selection: β”‚ + β”‚ β€’ Extension β”‚ + β”‚ β€’ Local Key β”‚ + β”‚ β€’ NIP-46 Connect β”‚ + β”‚ β€’ Read-only β”‚ + β”‚ β€’ OTP/DM β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ User selects method β”‚ + β”‚ and completes auth β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ Authentication β”‚ + β”‚ complete β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +### Authentication Storage & Persistence Flow + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Authentication β”‚ +β”‚ successful β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ nlMethodSelected β”‚ +β”‚ event dispatched β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” Extension? β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ AuthManager. │─────────────────▢│ Store verification β”‚ +β”‚ saveAuthState() β”‚ β”‚ data only (no β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ secrets) β”‚ + β”‚ Local Key? β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Encrypt secret key β”‚ +β”‚ with session β”‚ +β”‚ password + AES-GCM β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ NIP-46? + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Store connection β”‚ +β”‚ parameters (no β”‚ +β”‚ secrets) β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ Read-only? + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Store method only β”‚ +β”‚ (no secrets) β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” isolateSession? β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Choose storage: │─────────YES─────────▢│ sessionStorage β”‚ +β”‚ localStorage vs β”‚ β”‚ (per-window) β”‚ +β”‚ sessionStorage │◀────────NO──────────── β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ localStorage β”‚ +β”‚ (cross-window) β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +## Key Decision Points and Logic + +### 1. Extension Detection Logic (Line 994-1046) + +**Function:** `NostrLite._isRealExtension(obj)` + +```javascript +// Conservative extension detection +if (!obj || typeof obj !== 'object') return false; +if (typeof obj.getPublicKey !== 'function' || typeof obj.signEvent !== 'function') return false; + +// Exclude our own classes +const constructorName = obj.constructor?.name; +if (constructorName === 'WindowNostr' || constructorName === 'NostrLite') return false; +if (obj === window.NostrTools) return false; + +// Look for extension indicators +const extensionIndicators = [ + '_isEnabled', 'enabled', 'kind', '_eventEmitter', '_scope', + '_requests', '_pubkey', 'name', 'version', 'description' +]; +const hasIndicators = extensionIndicators.some(prop => obj.hasOwnProperty(prop)); +const hasExtensionConstructor = constructorName && + constructorName !== 'Object' && + constructorName !== 'Function'; + +return hasIndicators || hasExtensionConstructor; +``` + +### 2. Facade Installation Decision (Line 942-972) + +**Function:** `NostrLite._setupWindowNostrFacade()` + +``` +Extension detected? ──YES──▢ DON'T install facade + Store reference for persistence + β”‚ + NO + β–Ό +Install WindowNostr facade ──▢ Handle local/NIP-46/readonly methods +``` + +### 3. FloatingTab Click Behavior (Line 351-369) + +**Current UX Inconsistency Issue:** + +```javascript +async _handleClick() { + if (this.isAuthenticated && this.options.behavior.showUserInfo) { + this._showUserMenu(); // Show user options + } else { + // INCONSISTENCY: Auto-tries extension instead of opening modal + if (window.nostr && this._isRealExtension(window.nostr)) { + await this._tryExtensionLogin(window.nostr); // Automatic extension attempt + } else { + if (this.modal) { + this.modal.open({ startScreen: 'login' }); // Fallback to modal + } + } + } +} +``` + +**Comparison with Login Button behavior:** +- Login Button: **Always** opens modal for user choice +- FloatingTab: **Auto-tries extension first**, only shows modal if denied + +### 4. Authentication Restoration on Page Refresh + +**Two-Path System:** + +#### Path 1: Extension Mode (Line 1115-1173) +```javascript +async _attemptExtensionRestore() { + const authManager = new AuthManager({ isolateSession: this.options?.isolateSession }); + const storedAuth = await authManager.restoreAuthState(); + + if (!storedAuth || storedAuth.method !== 'extension') return null; + + // Verify extension still works with same pubkey + if (!window.nostr || !this._isRealExtension(window.nostr)) return null; + + const currentPubkey = await window.nostr.getPublicKey(); + if (currentPubkey !== storedAuth.pubkey) return null; + + // Dispatch nlAuthRestored event for UI updates + window.dispatchEvent(new CustomEvent('nlAuthRestored', { detail: extensionAuth })); +} +``` + +#### Path 2: Non-Extension Mode (Line 1080-1098) +```javascript +// Uses facade's restoreAuthState method +if (this.facadeInstalled && window.nostr?.restoreAuthState) { + const restoredAuth = await window.nostr.restoreAuthState(); + + if (restoredAuth) { + // Handle NIP-46 reconnection if needed + if (restoredAuth.requiresReconnection) { + this._showReconnectionPrompt(restoredAuth); + } + } +} +``` + +### 5. Storage Strategy (Line 1408-1414) + +**Storage Type Selection:** +```javascript +if (options.isolateSession) { + this.storage = sessionStorage; // Per-window isolation +} else { + this.storage = localStorage; // Cross-window persistence +} +``` + +### 6. Event-Driven State Synchronization + +**Key Events:** +- `nlMethodSelected` - Dispatched when user completes authentication +- `nlAuthRestored` - Dispatched when authentication is restored from storage +- `nlLogout` - Dispatched when user logs out +- `nlReconnectionRequired` - Dispatched when NIP-46 needs reconnection + +**Event Listeners:** +- FloatingTab listens to all auth events for UI updates (Line 271-295) +- WindowNostr listens to nlMethodSelected/nlLogout for state management (Line 823-869) + +## State Persistence Security Model + +### By Authentication Method: + +**Extension:** +- βœ… Store: pubkey, verification metadata +- ❌ Never store: extension object, secrets +- πŸ”’ Security: Minimal data, 1-hour expiry + +**Local Key:** +- βœ… Store: encrypted secret key, pubkey +- πŸ”’ Security: AES-GCM encryption with session-specific password +- πŸ”‘ Session password stored in sessionStorage (cleared on tab close) + +**NIP-46:** +- βœ… Store: connection parameters, pubkey +- ❌ Never store: session secrets +- πŸ”„ Requires: User reconnection on restore + +**Read-only:** +- βœ… Store: method type, pubkey +- ❌ No secrets to store + +## Current Issues Identified + +### UX Inconsistency (THE MAIN ISSUE) +**Problem:** FloatingTab and Login Button have different click behaviors +- **FloatingTab:** Auto-tries extension β†’ Falls back to modal if denied +- **Login Button:** Always opens modal for user choice + +**Impact:** +- Confusing user experience +- Inconsistent interaction patterns +- Users don't get consistent choice of authentication method + +**Root Cause:** Line 358-367 in FloatingTab._handleClick() method + +### Proposed Solutions: + +#### Option 1: Make FloatingTab Consistent (Recommended) +```javascript +async _handleClick() { + if (this.isAuthenticated && this.options.behavior.showUserInfo) { + this._showUserMenu(); + } else { + // Always open modal - consistent with login button + if (this.modal) { + this.modal.open({ startScreen: 'login' }); + } + } +} +``` + +#### Option 2: Add Configuration Option +```javascript +floatingTab: { + behavior: { + autoTryExtension: false, // Default to consistent behavior + // ... other options + } +} +``` + +## ⚠️ IMPLEMENTATION STATUS: READY FOR CODE CHANGES + +**User Decision:** FloatingTab should behave exactly like login buttons - always open modal for authentication method selection. + +**Required Changes:** +1. **File:** `lite/build.js` +2. **Method:** `FloatingTab._handleClick()` (lines 351-369) +3. **Action:** Remove extension auto-detection, always open modal + +**Current Code to Replace (lines 358-367):** +```javascript +// Check if extension is available for direct login +if (window.nostr && this._isRealExtension(window.nostr)) { + console.log('FloatingTab: Extension available, attempting direct extension login'); + await this._tryExtensionLogin(window.nostr); +} else { + // Open login modal + if (this.modal) { + this.modal.open({ startScreen: 'login' }); + } +} +``` + +**Replacement Code:** +```javascript +// Always open login modal (consistent with login buttons) +if (this.modal) { + this.modal.open({ startScreen: 'login' }); +} +``` + +**Critical Safety Notes:** +- βœ… **DO NOT** change `_checkExistingAuth()` method (lines 299-349) - this handles automatic restoration on page refresh +- βœ… **ONLY** change the click handler to remove manual extension detection +- βœ… Authentication restoration will continue to work properly via the separate restoration system +- βœ… Extension detection logic remains intact for other purposes (storage, verification, etc.) + +**After Implementation:** +- Rebuild the library with `node lite/build.js` +- Test that both floating tab and login buttons behave identically +- Verify that automatic login restoration on page refresh still works properly + +## Important Notes + +1. **Extension-First Architecture:** The system never interferes with real browser extensions +2. **Dual Storage Support:** Supports both per-window (sessionStorage) and cross-window (localStorage) persistence +3. **Security-First:** Sensitive data is always encrypted or not stored +4. **Event-Driven:** All components communicate via custom events +5. **Automatic Restoration:** Authentication state is automatically restored on page refresh when possible + +The login logic is complex due to supporting multiple authentication methods, security requirements, and different storage strategies, but it provides a flexible and secure authentication system for Nostr applications. \ No newline at end of file diff --git a/themes/default/theme.css b/themes/default/theme.css index 3c6561b..369a5b4 100644 --- a/themes/default/theme.css +++ b/themes/default/theme.css @@ -9,7 +9,7 @@ --nl-primary-color: #000000; --nl-secondary-color: #ffffff; --nl-accent-color: #ff0000; - --nl-muted-color: #666666; + --nl-muted-color: #CCCCCC; --nl-font-family: "Courier New", Courier, monospace; --nl-border-radius: 15px; --nl-border-width: 3px;