feat: persist cookies in app (#5318)

This commit is contained in:
Pooja
2025-08-19 22:10:22 +05:30
committed by GitHub
parent 146c8462ea
commit 3e3e2e0563
9 changed files with 431 additions and 32 deletions

View File

@@ -0,0 +1,43 @@
import { test, expect } from '../../playwright';
test('should persist cookies across app restarts', async ({ createTmpDir, launchElectronApp }) => {
// Create a temporary user-data directory so we control where the cookies store file is written.
const userDataPath = await createTmpDir('cookie-persistence');
const app1 = await launchElectronApp({ userDataPath });
const page1 = await app1.firstWindow();
await page1.waitForSelector('[data-trigger="cookies"]');
// Open Cookies modal via the status-bar button.
await page1.click('[data-trigger="cookies"]');
// When no cookies are present the modal shows a centred "Add Cookie" button.
await page1.getByRole('button', { name: /Add Cookie/i }).click();
// Fill out the form.
await page1.fill('input[name="domain"]', 'example.com');
await page1.fill('input[name="path"]', '/');
await page1.fill('input[name="key"]', 'session');
await page1.fill('input[name="value"]', 'abc123');
await page1.check('input[name="secure"]');
await page1.check('input[name="httpOnly"]');
await page1.getByRole('button', { name: 'Save' }).click();
await expect(page1.getByText('example.com')).toBeVisible();
await app1.close();
// Second launch verify the cookie was persisted and re-loaded
const app2 = await launchElectronApp({ userDataPath });
const page2 = await app2.firstWindow();
// Open the Cookies modal again.
await page2.waitForSelector('[data-trigger="cookies"]');
await page2.click('[data-trigger="cookies"]');
// The domain we added earlier should still be present.
await expect(page2.getByText('example.com')).toBeVisible();
await app2.close();
});

View File

@@ -0,0 +1,47 @@
import { test, expect } from '../../playwright';
import * as path from 'path';
import * as fs from 'fs/promises';
test('should handle corrupted passkey and still display saved cookie list', async ({ createTmpDir, launchElectronApp }) => {
const userDataPath = await createTmpDir('corrupted-passkey');
const app1 = await launchElectronApp({ userDataPath });
// 1. First run add a cookie via the UI so `cookies.json` is created.
const page1 = await app1.firstWindow();
await page1.waitForSelector('[data-trigger="cookies"]');
await page1.click('[data-trigger="cookies"]');
await page1.getByRole('button', { name: /Add Cookie/i }).click();
await page1.fill('input[name="domain"]', 'example.com');
await page1.fill('input[name="path"]', '/');
await page1.fill('input[name="key"]', 'session');
await page1.fill('input[name="value"]', 'abc123');
await page1.check('input[name="secure"]');
await page1.check('input[name="httpOnly"]');
await page1.getByRole('button', { name: 'Save' }).click();
await expect(page1.getByText('example.com')).toBeVisible();
await app1.close();
// 2. Corrupt the encryptedPasskey in cookies.json
const cookiesFilePath = path.join(userDataPath, 'cookies.json');
const raw = await fs.readFile(cookiesFilePath, 'utf-8');
const cookiesJson = JSON.parse(raw);
cookiesJson.encryptedPasskey = 'deadbeef'; // clearly invalid value
await fs.writeFile(cookiesFilePath, JSON.stringify(cookiesJson, null, 2));
// 3. Second run Bruno should recover and still list the cookie domain
const app2 = await launchElectronApp({ userDataPath });
const page2 = await app2.firstWindow();
await page2.waitForSelector('[data-trigger="cookies"]');
await page2.click('[data-trigger="cookies"]');
// The domain row should still be visible (even if cookie values are blank).
await expect(page2.getByText('example.com')).toBeVisible();
await app2.close();
});

View File

@@ -32,6 +32,8 @@ const { loadWindowState, saveBounds, saveMaximized } = require('./utils/window')
const registerNotificationsIpc = require('./ipc/notifications');
const registerGlobalEnvironmentsIpc = require('./ipc/global-environments');
const { safeParseJSON, safeStringifyJSON } = require('./utils/common');
const { getDomainsWithCookies } = require('./utils/cookies');
const { cookiesStore } = require('./store/cookies');
const lastOpenedCollections = new LastOpenedCollections();
@@ -179,7 +181,7 @@ app.on('ready', async () => {
mainWindow.minimize();
});
mainWindow.webContents.on('did-finish-load', () => {
mainWindow.webContents.on('did-finish-load', async () => {
let ogSend = mainWindow.webContents.send;
mainWindow.webContents.send = function(channel, ...args) {
return ogSend.apply(this, [channel, ...args?.map(_ => {
@@ -187,6 +189,14 @@ app.on('ready', async () => {
return safeParseJSON(safeStringifyJSON(_));
})]);
}
// Send cookies list after renderer is ready
try {
cookiesStore.initializeCookies();
const cookiesList = await getDomainsWithCookies();
mainWindow.webContents.send('main:cookies-update', cookiesList);
} catch (err) {
console.error('Failed to load cookies for renderer', err);
}
});
// register all ipc handlers
@@ -198,6 +208,14 @@ app.on('ready', async () => {
});
// Quit the app once all windows are closed
app.on('before-quit', () => {
try {
cookiesStore.saveCookieJar(true);
} catch (err) {
console.warn('Failed to flush cookies on quit', err);
}
});
app.on('window-all-closed', app.quit);
// Open collection from Recent menu (#1521)

View File

@@ -19,6 +19,7 @@ const {
} = require('@usebruno/filestore');
const brunoConverters = require('@usebruno/converters');
const { postmanToBruno } = brunoConverters;
const { cookiesStore } = require('../store/cookies');
const { parseLargeRequestWithRedaction } = require('../utils/parse');
const {
@@ -890,12 +891,20 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
}
});
const updateCookiesAndNotify = async () => {
const domainsWithCookies = await getDomainsWithCookies();
mainWindow.webContents.send(
'main:cookies-update',
safeParseJSON(safeStringifyJSON(domainsWithCookies))
);
cookiesStore.saveCookieJar();
};
// Delete all cookies for a domain
ipcMain.handle('renderer:delete-cookies-for-domain', async (event, domain) => {
try {
await deleteCookiesForDomain(domain);
const domainsWithCookies = await getDomainsWithCookies();
mainWindow.webContents.send('main:cookies-update', safeParseJSON(safeStringifyJSON(domainsWithCookies)));
await updateCookiesAndNotify();
} catch (error) {
return Promise.reject(error);
}
@@ -904,8 +913,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
ipcMain.handle('renderer:delete-cookie', async (event, domain, path, cookieKey) => {
try {
await deleteCookie(domain, path, cookieKey);
const domainsWithCookies = await getDomainsWithCookies();
mainWindow.webContents.send('main:cookies-update', safeParseJSON(safeStringifyJSON(domainsWithCookies)));
await updateCookiesAndNotify();
} catch (error) {
return Promise.reject(error);
}
@@ -915,8 +923,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
ipcMain.handle('renderer:add-cookie', async (event, domain, cookie) => {
try {
await addCookieForDomain(domain, cookie);
const domainsWithCookies = await getDomainsWithCookies();
mainWindow.webContents.send('main:cookies-update', safeParseJSON(safeStringifyJSON(domainsWithCookies)));
await updateCookiesAndNotify();
} catch (error) {
return Promise.reject(error);
}
@@ -926,8 +933,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
ipcMain.handle('renderer:modify-cookie', async (event, domain, oldCookie, cookie) => {
try {
await modifyCookieForDomain(domain, oldCookie, cookie);
const domainsWithCookies = await getDomainsWithCookies();
mainWindow.webContents.send('main:cookies-update', safeParseJSON(safeStringifyJSON(domainsWithCookies)));
await updateCookiesAndNotify();
} catch (error) {
return Promise.reject(error);
}

View File

@@ -32,6 +32,7 @@ const { getProcessEnvVars } = require('../../store/process-env');
const { getBrunoConfig } = require('../../store/bruno-config');
const Oauth2Store = require('../../store/oauth2');
const { isRequestTagsIncluded } = require('@usebruno/common');
const { cookiesStore } = require('../../store/cookies');
const saveCookies = (url, headers) => {
if (preferencesUtil.shouldStoreCookies()) {
@@ -771,6 +772,7 @@ const registerNetworkIpc = (mainWindow) => {
const domainsWithCookies = await getDomainsWithCookies();
mainWindow.webContents.send('main:cookies-update', safeParseJSON(safeStringifyJSON(domainsWithCookies)));
cookiesStore.saveCookieJar();
let postResponseScriptResult = null;
let postResponseError = null;
@@ -900,6 +902,7 @@ const registerNetworkIpc = (mainWindow) => {
const domainsWithCookiesTest = await getDomainsWithCookies();
mainWindow.webContents.send('main:cookies-update', safeParseJSON(safeStringifyJSON(domainsWithCookiesTest)));
cookiesStore.saveCookieJar();
}
return {

View File

@@ -0,0 +1,192 @@
const Store = require('electron-store');
const { cookies: cookiesModule } = require('@usebruno/common');
const { cookieJar } = cookiesModule;
const { Cookie } = require('tough-cookie');
const { createCookieString } = cookiesModule;
const crypto = require('crypto');
const { encryptString, decryptString } = require('../utils/encryption');
const DEBOUNCE_MS = 5000; // Debounce duration (ms) for persisting cookie jar
class CookiesStore {
#saveTimerId = null;
#debounceStart = null; // Track first debounce time
#passkey = null;
constructor() {
this.store = new Store({
name: 'cookies',
clearInvalidConfig: true,
defaults: {
encryptedPasskey: null,
cookies: {}
}
});
this.initializeEncryption();
}
#generatePasskey() {
// Generate 32 bytes (256 bits) of random data and convert to hex
return crypto.randomBytes(32).toString('hex');
}
initializeEncryption() {
try {
let encryptedPasskey = this.store.get('encryptedPasskey');
if (!encryptedPasskey) {
// Generate cryptographically secure random passkey
const passkey = this.#generatePasskey();
encryptedPasskey = encryptString(passkey);
if (!encryptedPasskey) {
console.warn('Failed to encrypt new passkey, falling back to unencrypted cookies');
this.#passkey = null;
return;
}
this.store.set('encryptedPasskey', encryptedPasskey);
}
this.#passkey = decryptString(encryptedPasskey);
if (!this.#passkey) {
console.warn('Failed to decrypt passkey, falling back to unencrypted cookies');
}
} catch (err) {
console.warn('Failed to initialize encryption, falling back to unencrypted cookies:', err);
this.#passkey = null;
}
}
getCookies() {
const cookieStore = this.store.get('cookies', {});
const decryptedCookies = [];
// Filter and decrypt cookies
Object.values(cookieStore).forEach(domainCookies => {
if (!Array.isArray(domainCookies)) return;
domainCookies.forEach(cookie => {
try {
// Create cookie with decrypted value
const decryptedCookie = {
...cookie,
value: decryptString(cookie.value, this.#passkey)
};
decryptedCookies.push(decryptedCookie);
} catch (err) {
console.warn('Failed to process cookie:', cookie?.key, err);
// Still add the cookie but with empty value if processing fails
decryptedCookies.push({
...cookie,
value: ''
});
}
});
});
return decryptedCookies;
}
setCookies(cookies) {
try {
// Organize cookies by domain
const cookiesByDomain = {};
cookies.cookies.forEach(cookie => {
try {
if (!cookiesByDomain[cookie.domain]) {
cookiesByDomain[cookie.domain] = [];
}
cookiesByDomain[cookie.domain].push({
...cookie,
value: encryptString(cookie.value, this.#passkey)
});
} catch (err) {
console.warn('Failed to process cookie for storage:', cookie?.key, err);
// Still store the cookie but with original value if encryption fails
if (!cookiesByDomain[cookie.domain]) {
cookiesByDomain[cookie.domain] = [];
}
cookiesByDomain[cookie.domain].push(cookie);
}
});
return this.store.set('cookies', cookiesByDomain);
} catch (err) {
console.warn('Failed to set cookies:', err);
}
}
// Initialize cookies from store into cookie jar
initializeCookies() {
try {
const storedCookies = this.getCookies();
if (Array.isArray(storedCookies) && storedCookies.length) {
storedCookies.forEach((cookie) => this.loadCookieIntoJar(cookie));
}
} catch (err) {
console.warn('Failed to initialize cookies:', err);
}
}
// Load a single cookie into the cookie jar
loadCookieIntoJar(rawCookie) {
try {
const cookie = Cookie.fromJSON(rawCookie);
if (!cookie) return;
// Re-assemble request URL for tough-cookie
const protocol = cookie.secure ? 'https' : 'http';
const domain = cookie.domain.startsWith('.') ? cookie.domain.slice(1) : cookie.domain;
const url = `${protocol}://${domain}${cookie.path || '/'}`;
const setCookieHeader = createCookieString(cookie);
cookieJar.setCookieSync(setCookieHeader, url, { ignoreError: true });
} catch (err) {
console.warn('Failed to load cookie:', rawCookie?.key, err?.message);
}
}
// Save current cookie jar state to store with debouncing
writeCookieJar() {
try {
const serialized = cookieJar.serializeSync();
this.setCookies(serialized);
} catch (err) {
console.warn('Failed to save cookie jar:', err);
} finally {
this.#debounceStart = null;
}
}
saveCookieJar(immediate = false) {
// Debounced write to avoid excessive disk I/O during rapid request bursts
if (immediate) {
if (this.#saveTimerId) {
clearTimeout(this.#saveTimerId);
this.#saveTimerId = null;
}
return this.writeCookieJar();
}
if (!this.#debounceStart) {
this.#debounceStart = Date.now();
}
if (this.#saveTimerId) {
clearTimeout(this.#saveTimerId);
}
this.#saveTimerId = setTimeout(() => {
this.writeCookieJar();
this.#saveTimerId = null;
}, DEBOUNCE_MS);
}
}
// Create singleton instance
const cookiesStore = new CookiesStore();
module.exports = {
cookiesStore,
CookiesStore
};

View File

@@ -29,8 +29,8 @@ function deriveKeyAndIv(password, keyLength, ivLength) {
return { key, iv };
}
function aes256Encrypt(data) {
const rawKey = machineIdSync();
function aes256Encrypt(data, passkey = null) {
const rawKey = passkey || machineIdSync();
const iv = Buffer.alloc(16, 0); // Default IV for new encryption
const key = crypto.createHash('sha256').update(rawKey).digest(); // Derive a 32-byte key
const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
@@ -40,8 +40,8 @@ function aes256Encrypt(data) {
return encrypted;
}
function aes256Decrypt(data) {
const rawKey = machineIdSync();
function aes256Decrypt(data, passkey = null) {
const rawKey = passkey || machineIdSync();
// Attempt to decrypt using new method first
const iv = Buffer.alloc(16, 0); // Default IV for new encryption
@@ -95,7 +95,7 @@ function safeStorageDecrypt(str) {
}
}
function encryptString(str) {
function encryptString(str, passkey = null) {
if (typeof str !== 'string') {
throw new Error('Encrypt failed: invalid string');
}
@@ -103,20 +103,31 @@ function encryptString(str) {
return '';
}
let encryptedString = '';
// If a passkey is provided (from cookies store), we must use it for encryption.
if (passkey !== null && passkey !== undefined) {
if (typeof passkey !== 'string' || passkey.length === 0) {
// Corrupted / empty passkey -> do not encrypt, return empty value
return '';
}
try {
const encryptedString = aes256Encrypt(str, passkey);
return `$${AES256_ALGO}:${encryptedString}`;
} catch (err) {
// Any error indicates the passkey is unusable; return empty string
return '';
}
}
if (safeStorage && safeStorage.isEncryptionAvailable()) {
encryptedString = safeStorageEncrypt(str);
const encryptedString = safeStorageEncrypt(str);
return `$${ELECTRONSAFESTORAGE_ALGO}:${encryptedString}`;
}
// fallback to aes256
encryptedString = aes256Encrypt(str);
const encryptedString = aes256Encrypt(str);
return `$${AES256_ALGO}:${encryptedString}`;
}
function decryptString(str) {
function decryptString(str, passkey = null) {
if (typeof str !== 'string') {
throw new Error('Decrypt failed: unrecognized string format');
}
@@ -148,8 +159,9 @@ function decryptString(str) {
}
if (algo === AES256_ALGO) {
return aes256Decrypt(encryptedString);
return aes256Decrypt(encryptedString, passkey || null);
}
throw new Error('Decrypt failed: Invalid algo');
}
function decryptStringSafe(str) {

View File

@@ -0,0 +1,71 @@
const path = require('path');
const mockEncrypt = (str) => (str.length ? `$enc:${str}` : '');
const mockDecrypt = (str) => str.replace(/^\$enc:/, '');
jest.mock('../src/utils/encryption', () => ({
encryptString: jest.fn(mockEncrypt),
decryptString: jest.fn(mockDecrypt)
}));
jest.mock('electron-store', () => {
return jest.fn().mockImplementation((opts = {}) => {
const data = { ...(opts.defaults || {}) };
return {
get: (key, fallback) => (key in data ? data[key] : fallback),
set: (key, value) => {
data[key] = value;
}
};
});
});
const { CookiesStore } = require(path.join('..', 'src', 'store', 'cookies'));
function createFreshStore() {
return new CookiesStore();
}
describe('CookiesStore', () => {
test('setCookies encrypts values and getCookies returns decrypted values', () => {
const store = createFreshStore();
const cookie = {
domain: 'example.com',
key: 'auth',
value: 'token',
path: '/',
secure: true,
httpOnly: true
};
store.setCookies({ cookies: [cookie] });
// Raw persisted value should be encrypted
const raw = store.store.get('cookies');
expect(raw['example.com'][0].value).toBe(`$enc:${cookie.value}`);
// API should return decrypted value
const retrieved = store.getCookies();
expect(retrieved[0].value).toBe(cookie.value);
});
test('getCookies leaves plain-text cookie values untouched', () => {
const store = createFreshStore();
const plainCookie = {
domain: 'example.com',
key: 'sid',
value: 'plaintext',
path: '/',
secure: false,
httpOnly: false
};
// Manually inject to the underlying store to simulate legacy/plain data
store.store.set('cookies', { 'example.com': [plainCookie] });
const cookies = store.getCookies();
expect(cookies[0].value).toBe('plaintext');
});
});

View File

@@ -14,9 +14,9 @@ export const test = baseTest.extend<
},
{
createTmpDir: (tag?: string) => Promise<string>;
launchElectronApp: (options?: { initUserDataPath?: string }) => Promise<ElectronApplication>;
launchElectronApp: (options?: { initUserDataPath?: string; userDataPath?: string }) => Promise<ElectronApplication>;
electronApp: ElectronApplication;
reuseOrLaunchElectronApp: (options?: { initUserDataPath?: string }) => Promise<ElectronApplication>;
reuseOrLaunchElectronApp: (options?: { initUserDataPath?: string; userDataPath?: string }) => Promise<ElectronApplication>;
}
>({
createTmpDir: [
@@ -37,8 +37,13 @@ export const test = baseTest.extend<
launchElectronApp: [
async ({ playwright, createTmpDir }, use, workerInfo) => {
const apps: ElectronApplication[] = [];
await use(async ({ initUserDataPath } = {}) => {
const userDataPath = await createTmpDir('electron-userdata');
await use(async ({ initUserDataPath, userDataPath: providedUserDataPath } = {}) => {
const userDataPath = providedUserDataPath || (await createTmpDir('electron-userdata'));
// Ensure dir exists when caller supplies their own path
if (providedUserDataPath) {
await fs.promises.mkdir(userDataPath, { recursive: true });
}
if (initUserDataPath) {
const replacements = {
@@ -67,10 +72,10 @@ export const test = baseTest.extend<
});
const { workerIndex } = workerInfo;
app.process().stdout.on('data', (data) => {
app.process()?.stdout?.on('data', (data) => {
process.stdout.write(data.toString().replace(/^(?=.)/gm, `[Electron #${workerIndex}] |`));
});
app.process().stderr.on('data', (error) => {
app.process()?.stderr?.on('data', (error) => {
process.stderr.write(error.toString().replace(/^(?=.)/gm, `[Electron #${workerIndex}] |`));
});
@@ -137,13 +142,15 @@ export const test = baseTest.extend<
reuseOrLaunchElectronApp: [
async ({ launchElectronApp }, use, testInfo) => {
const apps: Record<string, ElectronApplication> = {};
await use(async ({ initUserDataPath } = {}) => {
const key = initUserDataPath;
await use(async ({ initUserDataPath, userDataPath } = {}) => {
const key = userDataPath || initUserDataPath;
if (key && apps[key]) {
return apps[key];
}
const app = await launchElectronApp({ initUserDataPath });
apps[key] = app;
const app = await launchElectronApp({ initUserDataPath, userDataPath });
if (key) {
apps[key] = app;
}
return app;
});
},