184 lines
5.9 KiB
TypeScript
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)
|
|
}
|
|
}
|
|
|