You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
227 lines
7.1 KiB
227 lines
7.1 KiB
/** |
|
* Session manager for active user sessions |
|
*/ |
|
|
|
import type { NostrEvent } from '../../types/nostr.js'; |
|
|
|
export type AuthMethod = 'nip07' | 'nsec' | 'anonymous'; |
|
|
|
export interface UserSession { |
|
pubkey: string; |
|
method: AuthMethod; |
|
signer: (event: Omit<NostrEvent, 'sig' | 'id'>) => Promise<NostrEvent>; |
|
createdAt: number; |
|
// Store password in memory for nsec/anonymous sessions (never persisted) |
|
// This allows signing without re-prompting, but password is cleared on logout |
|
password?: string; // Only for 'nsec' and 'anonymous' methods |
|
} |
|
|
|
// Simple store implementation for Svelte reactivity |
|
function createStore<T>(initial: T) { |
|
let value = initial; |
|
const subscribers = new Set<(value: T) => void>(); |
|
|
|
return { |
|
get value() { |
|
return value; |
|
}, |
|
set(newValue: T) { |
|
value = newValue; |
|
subscribers.forEach((fn) => fn(value)); |
|
}, |
|
subscribe(fn: (value: T) => void) { |
|
subscribers.add(fn); |
|
fn(value); |
|
return () => subscribers.delete(fn); |
|
} |
|
}; |
|
} |
|
|
|
class SessionManager { |
|
private currentSession: UserSession | null = null; |
|
public session = createStore<UserSession | null>(null); |
|
|
|
/** |
|
* Set current session |
|
*/ |
|
setSession(session: UserSession, metadata?: Record<string, any>): void { |
|
this.currentSession = session; |
|
this.session.set(session); |
|
// Store in localStorage for persistence |
|
// NEVER store password in localStorage - it's only kept in memory |
|
if (typeof window !== 'undefined') { |
|
const sessionData: any = { |
|
pubkey: session.pubkey, |
|
method: session.method, |
|
createdAt: session.createdAt |
|
}; |
|
// Store method-specific metadata for restoration |
|
if (metadata) { |
|
sessionData.metadata = metadata; |
|
} |
|
// Password is never persisted - only kept in memory |
|
localStorage.setItem('aitherboard_session', JSON.stringify(sessionData)); |
|
} |
|
} |
|
|
|
/** |
|
* Get current session |
|
*/ |
|
getSession(): UserSession | null { |
|
return this.currentSession; |
|
} |
|
|
|
/** |
|
* Check if user is logged in |
|
*/ |
|
isLoggedIn(): boolean { |
|
return this.currentSession !== null; |
|
} |
|
|
|
/** |
|
* Get current pubkey |
|
*/ |
|
getCurrentPubkey(): string | null { |
|
return this.currentSession?.pubkey || null; |
|
} |
|
|
|
/** |
|
* Sign event with current session |
|
*/ |
|
async signEvent(event: Omit<NostrEvent, 'sig' | 'id'>): Promise<NostrEvent> { |
|
if (!this.currentSession) { |
|
throw new Error('No active session'); |
|
} |
|
|
|
return this.currentSession.signer(event); |
|
} |
|
|
|
/** |
|
* Clear session |
|
* Also clears password from memory for security |
|
*/ |
|
clearSession(): void { |
|
// Clear password from memory if it exists |
|
if (this.currentSession?.password) { |
|
// Overwrite password in memory (though JS doesn't guarantee this) |
|
this.currentSession.password = ''; |
|
} |
|
this.currentSession = null; |
|
this.session.set(null); |
|
if (typeof window !== 'undefined') { |
|
localStorage.removeItem('aitherboard_session'); |
|
} |
|
} |
|
|
|
/** |
|
* Restore session from localStorage |
|
* This will attempt to restore the session based on the auth method |
|
* Only restores if there's no active session (to avoid overwriting sessions with passwords) |
|
*/ |
|
async restoreSession(): Promise<boolean> { |
|
if (typeof window === 'undefined') return false; |
|
|
|
// Don't restore if there's already an active session (especially one with a password) |
|
if (this.currentSession) { |
|
return true; // Session already exists, consider it restored |
|
} |
|
|
|
const stored = localStorage.getItem('aitherboard_session'); |
|
if (!stored) return false; |
|
|
|
try { |
|
const data = JSON.parse(stored); |
|
const { pubkey, method, metadata } = data; |
|
|
|
if (!pubkey || !method) return false; |
|
|
|
// Import auth handlers dynamically to avoid circular dependencies |
|
switch (method) { |
|
case 'nip07': { |
|
// For NIP-07, we can restore by checking if extension is still available |
|
const { isNIP07Available, getPublicKeyWithNIP07, signEventWithNIP07 } = await import('./nip07-signer.js'); |
|
if (isNIP07Available()) { |
|
try { |
|
// Verify the extension still has the same pubkey |
|
const currentPubkey = await getPublicKeyWithNIP07(); |
|
if (currentPubkey === pubkey) { |
|
this.setSession({ |
|
pubkey, |
|
method: 'nip07', |
|
signer: signEventWithNIP07, |
|
createdAt: data.createdAt || Date.now() |
|
}); |
|
return true; |
|
} |
|
} catch { |
|
// Extension error, can't restore |
|
return false; |
|
} |
|
} |
|
return false; |
|
} |
|
case 'anonymous': { |
|
// For anonymous, we can restore if the encrypted key is stored |
|
// The key is stored in IndexedDB, we just need to verify it exists |
|
// We can't restore without password, but we can check if key exists |
|
if (pubkey) { |
|
try { |
|
// Check if key exists by trying to list keys (we can't decrypt without password) |
|
// For now, restore session but signer will require password |
|
this.setSession({ |
|
pubkey, |
|
method: 'anonymous', |
|
signer: async () => { |
|
throw new Error('Anonymous session requires password. Please log in again.'); |
|
}, |
|
createdAt: data.createdAt || Date.now() |
|
}); |
|
// Note: This session won't work until user re-authenticates with password |
|
return true; |
|
} catch { |
|
return false; |
|
} |
|
} |
|
return false; |
|
} |
|
case 'nsec': { |
|
// For nsec, we can restore the session but signing will require password |
|
// The encrypted key is stored in IndexedDB, but we need password to decrypt |
|
// For now, restore session but signer will fail until user re-enters password |
|
if (pubkey) { |
|
try { |
|
const { hasNsecKey } = await import('../cache/nsec-key-store.js'); |
|
const keyExists = await hasNsecKey(pubkey); |
|
if (keyExists) { |
|
// Restore session but signer will require password |
|
this.setSession({ |
|
pubkey, |
|
method: 'nsec', |
|
signer: async () => { |
|
throw new Error('Nsec session requires password. Please log in again.'); |
|
}, |
|
createdAt: data.createdAt || Date.now() |
|
}); |
|
return true; |
|
} |
|
} catch { |
|
return false; |
|
} |
|
} |
|
// Clear the stored session if key doesn't exist |
|
localStorage.removeItem('aitherboard_session'); |
|
return false; |
|
} |
|
default: |
|
return false; |
|
} |
|
} catch (error) { |
|
console.error('Error restoring session:', error); |
|
// Clear corrupted session data |
|
localStorage.removeItem('aitherboard_session'); |
|
return false; |
|
} |
|
} |
|
} |
|
|
|
export const sessionManager = new SessionManager();
|
|
|