/** * SaaS Session Persistence * * Handles loading/saving SaaS auth session data. * Token is stored in secure storage (OS keyring), not plain localStorage. * Auth state is carried by HttpOnly cookies when possible (same-origin). */ import type { SaaSAccountInfo } from './saas-types'; import { createLogger } from './logger'; const logger = createLogger('saas-session'); // === Storage Keys === const SAAS_TOKEN_SECURE_KEY = 'zclaw-saas-token'; // OS keyring key const SAAS_REFRESH_TOKEN_KEY = 'zclaw-saas-refresh-token'; // OS keyring key for refresh token const SAASTOKEN_KEY = 'zclaw-saas-token'; // legacy localStorage — only used for cleanup const SAASURL_KEY = 'zclaw-saas-url'; const SAASACCOUNT_KEY = 'zclaw-saas-account'; const SAASMODE_KEY = 'zclaw-connection-mode'; // === Session Interface === export interface SaaSSession { token: string | null; // null when using cookie-based auth (page reload) refreshToken: string | null; // for token refresh on restore account: SaaSAccountInfo | null; saasUrl: string; } // === Session Functions === /** * Load a persisted SaaS session. * Token is stored in secure storage (OS keyring), not plain localStorage. * Returns null if no URL is stored (never logged in). * * NOTE: Token loading is async due to secure storage access. * For synchronous checks, use loadSaaSSessionSync() (URL + account only). */ export async function loadSaaSSession(): Promise { try { const saasUrl = localStorage.getItem(SAASURL_KEY); if (!saasUrl) { return null; } // Clean up any legacy plaintext token from localStorage const legacyToken = localStorage.getItem(SAASTOKEN_KEY); if (legacyToken) { localStorage.removeItem(SAASTOKEN_KEY); } // Load token from secure storage let token: string | null = null; let refreshToken: string | null = null; try { const { secureStorage } = await import('./secure-storage'); token = await secureStorage.get(SAAS_TOKEN_SECURE_KEY); refreshToken = await secureStorage.get(SAAS_REFRESH_TOKEN_KEY); } catch (e) { logger.debug('Secure storage unavailable for token load', { error: e }); // Secure storage unavailable — token stays null (cookie auth will be attempted) } const accountRaw = localStorage.getItem(SAASACCOUNT_KEY); const account: SaaSAccountInfo | null = accountRaw ? (JSON.parse(accountRaw) as SaaSAccountInfo) : null; return { token, refreshToken, account, saasUrl }; } catch (e) { logger.debug('Corrupted session data, clearing', { error: e }); // Corrupted data - clear all clearSaaSSession(); return null; } } /** * Synchronous version — returns URL + account only (no token). * Used during store initialization where async is not available. */ export function loadSaaSSessionSync(): { saasUrl: string; account: SaaSAccountInfo | null } | null { try { const saasUrl = localStorage.getItem(SAASURL_KEY); if (!saasUrl) return null; // Clean up legacy plaintext token const legacyToken = localStorage.getItem(SAASTOKEN_KEY); if (legacyToken) { localStorage.removeItem(SAASTOKEN_KEY); } const accountRaw = localStorage.getItem(SAASACCOUNT_KEY); const account: SaaSAccountInfo | null = accountRaw ? (JSON.parse(accountRaw) as SaaSAccountInfo) : null; return { saasUrl, account }; } catch (e) { logger.debug('Failed to load sync session', { error: e }); return null; } } /** * Persist SaaS session. * Access token + refresh token go to secure storage (OS keyring), metadata to localStorage. */ export async function saveSaaSSession(session: SaaSSession): Promise { // Store access token in secure storage (OS keyring) if (session.token) { try { const { secureStorage } = await import('./secure-storage'); await secureStorage.set(SAAS_TOKEN_SECURE_KEY, session.token); } catch (e) { logger.debug('Secure storage unavailable for token save', { error: e }); } } // Store refresh token in secure storage (OS keyring) if (session.refreshToken) { try { const { secureStorage } = await import('./secure-storage'); await secureStorage.set(SAAS_REFRESH_TOKEN_KEY, session.refreshToken); } catch (e) { logger.debug('Secure storage unavailable for refresh token save', { error: e }); } } localStorage.setItem(SAASURL_KEY, session.saasUrl); if (session.account) { localStorage.setItem(SAASACCOUNT_KEY, JSON.stringify(session.account)); } } /** * Clear the persisted SaaS session from all storage. */ export async function clearSaaSSession(): Promise { // Remove access token from secure storage try { const { secureStorage } = await import('./secure-storage'); await secureStorage.set(SAAS_TOKEN_SECURE_KEY, ''); } catch (e) { logger.debug('Failed to clear secure storage token', { error: e }); } // Remove refresh token from secure storage try { const { secureStorage } = await import('./secure-storage'); await secureStorage.set(SAAS_REFRESH_TOKEN_KEY, ''); } catch (e) { logger.debug('Failed to clear secure storage refresh token', { error: e }); } localStorage.removeItem(SAASTOKEN_KEY); localStorage.removeItem(SAASURL_KEY); localStorage.removeItem(SAASACCOUNT_KEY); } /** * Persist the connection mode to localStorage with timestamp. */ export function saveConnectionMode(mode: string): void { const data = JSON.stringify({ mode, timestamp: Date.now() }); localStorage.setItem(SAASMODE_KEY, data); } /** * Load the connection mode from localStorage. * Handles both new JSON format and legacy plain string format. * Returns null if not set. */ export function loadConnectionMode(): string | null { const raw = localStorage.getItem(SAASMODE_KEY); if (!raw) return null; try { const parsed = JSON.parse(raw); if (typeof parsed === 'string') return parsed; // legacy format return parsed.mode ?? null; } catch { return raw; // legacy format (plain string) } }