Files
zclaw_openfang/desktop/src/lib/saas-session.ts

184 lines
5.9 KiB
TypeScript

/**
* 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<SaaSSession | null> {
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<void> {
// 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<void> {
// 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)
}
}