qik.storage.js

/**
 * Storage abstraction layer for handling both localStorage and httpOnly cookie storage
 * @module QikStorage
 */

/**
 * Base storage interface
 */
class StorageAdapter {
    constructor() {
        if (this.constructor === StorageAdapter) {
            throw new Error("StorageAdapter is an abstract class and cannot be instantiated directly");
        }
    }

    /**
     * Get user session data
     * @returns {Object|null} User session object
     */
    getUser() {
        throw new Error("getUser method must be implemented");
    }

    /**
     * Set user session data
     * @param {Object} user User session object
     */
    setUser(user) {
        throw new Error("setUser method must be implemented");
    }

    /**
     * Clear user session data
     */
    clearUser() {
        throw new Error("clearUser method must be implemented");
    }

    /**
     * Get current access token
     * @returns {String|null} Access token
     */
    getAccessToken() {
        throw new Error("getAccessToken method must be implemented");
    }

    /**
     * Get current refresh token
     * @returns {String|null} Refresh token
     */
    getRefreshToken() {
        throw new Error("getRefreshToken method must be implemented");
    }

    /**
     * Get token expiry date
     * @returns {String|null} Token expiry date
     */
    getTokenExpiry() {
        throw new Error("getTokenExpiry method must be implemented");
    }

    /**
     * Check if storage is available
     * @returns {Boolean} Whether storage is available
     */
    isAvailable() {
        throw new Error("isAvailable method must be implemented");
    }
}

/**
 * localStorage-based storage adapter (current behavior)
 */
class LocalStorageAdapter extends StorageAdapter {
    constructor() {
        super();
        this.storage = {};
    }

    getUser() {
        return this.storage.user || null;
    }

    setUser(user) {
        this.storage.user = user;
    }

    clearUser() {
        delete this.storage.user;
    }

    getAccessToken() {
        const user = this.getUser();
        return user?.token?.accessToken || null;
    }

    getRefreshToken() {
        const user = this.getUser();
        return user?.token?.refreshToken || null;
    }

    getTokenExpiry() {
        const user = this.getUser();
        return user?.token?.expires || null;
    }

    isAvailable() {
        return true; // Always available as it uses in-memory storage
    }
}

/**
 * Cookie-based storage adapter for httpOnly cookies
 */
class CookieStorageAdapter extends StorageAdapter {
    constructor(options = {}) {
        super();
        this.options = {
            domain: options.domain || this._detectDomain(),
            secure: options.secure !== false, // Default to true
            sameSite: options.sameSite || 'lax',
            ...options
        };
        this.sessionStorage = {}; // In-memory storage for session data
    }

    /**
     * Auto-detect appropriate cookie domain
     * @private
     */
    _detectDomain() {
        if (typeof window === 'undefined') return null;
        
        const hostname = window.location.hostname;
        
        // For localhost, don't set domain
        if (hostname === 'localhost' || hostname === '127.0.0.1') {
            return null;
        }
        
        // For IP addresses, don't set domain
        if (/^\d+\.\d+\.\d+\.\d+$/.test(hostname)) {
            return null;
        }
        
        // For subdomains, use parent domain (e.g., .example.com)
        const parts = hostname.split('.');
        if (parts.length > 2) {
            return '.' + parts.slice(-2).join('.');
        }
        
        return null; // Let browser handle single domain
    }

    /**
     * Read a cookie value
     * @private
     */
    _getCookie(name) {
        if (typeof document === 'undefined') return null;
        
        const value = `; ${document.cookie}`;
        const parts = value.split(`; ${name}=`);
        if (parts.length === 2) {
            return parts.pop().split(';').shift();
        }
        return null;
    }

    /**
     * Set a cookie value (for non-httpOnly cookies only)
     * @private
     */
    _setCookie(name, value, options = {}) {
        if (typeof document === 'undefined') return;
        
        const opts = { ...this.options, ...options };
        let cookieString = `${name}=${value}`;
        
        if (opts.domain) {
            cookieString += `; Domain=${opts.domain}`;
        }
        
        if (opts.secure) {
            cookieString += '; Secure';
        }
        
        if (opts.sameSite) {
            cookieString += `; SameSite=${opts.sameSite}`;
        }
        
        if (opts.maxAge) {
            cookieString += `; Max-Age=${opts.maxAge}`;
        }
        
        document.cookie = cookieString;
    }

    /**
     * Delete a cookie
     * @private
     */
    _deleteCookie(name) {
        this._setCookie(name, '', { maxAge: 0 });
    }

    getUser() {
        return this.sessionStorage.user || null;
    }

    setUser(user) {
        if (user) {
            // Store session data in memory (accessible to JavaScript)
            const { token, ...sessionData } = user;
            this.sessionStorage.user = sessionData;
            
            // Tokens are handled by httpOnly cookies set by the server
            // We don't store them in JavaScript for security
        } else {
            this.sessionStorage.user = null;
        }
    }

    clearUser() {
        delete this.sessionStorage.user;
        // Note: httpOnly cookies will be cleared by server response
        // But we can clear any non-httpOnly cookies we might have set
        this._deleteCookie('qik_session_id');
    }

    getAccessToken() {
        // In cookie mode, we can't access httpOnly cookies from JavaScript
        // The browser will automatically include them in requests
        // Return null to indicate tokens are handled by cookies
        return null;
    }

    getRefreshToken() {
        // Same as access token - handled by httpOnly cookies
        return null;
    }

    getTokenExpiry() {
        // We can't read httpOnly cookie expiry from JavaScript
        // The server will handle token expiry validation
        return null;
    }

    isAvailable() {
        // Check if we're in a browser environment and cookies are enabled
        if (typeof document === 'undefined') return false;
        
        try {
            // Test if we can set/read cookies
            const testCookie = 'qik_test_cookie';
            this._setCookie(testCookie, 'test');
            const canRead = this._getCookie(testCookie) === 'test';
            this._deleteCookie(testCookie);
            return canRead;
        } catch (e) {
            return false;
        }
    }

    /**
     * Check if we're in cookie mode
     * @returns {Boolean}
     */
    isCookieMode() {
        return true;
    }

    /**
     * Get cookie configuration options
     * @returns {Object}
     */
    getCookieOptions() {
        return { ...this.options };
    }
}

/**
 * Factory function to create appropriate storage adapter
 * @param {Object} options Configuration options
 * @param {Boolean} options.useHttpOnlyCookies Whether to use cookie storage
 * @param {Object} options.cookieConfig Cookie configuration options
 * @returns {StorageAdapter} Storage adapter instance
 */
export function createStorageAdapter(options = {}) {
    if (options.useHttpOnlyCookies) {
        const cookieAdapter = new CookieStorageAdapter(options.cookieConfig);
        
        // Fallback to localStorage if cookies aren't available
        if (!cookieAdapter.isAvailable()) {
            console.warn('Qik SDK: Cookies not available, falling back to localStorage mode');
            return new LocalStorageAdapter();
        }
        
        return cookieAdapter;
    }
    
    return new LocalStorageAdapter();
}

export { StorageAdapter, LocalStorageAdapter, CookieStorageAdapter };