diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d83e9b..8340065 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to GitHub Devwatch will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Security +- GitHub sign-in sessions now stay in Chrome session storage only instead of persisting to local extension storage +- Added validation for the GitHub device-flow verification URL before opening a browser tab +- Tightened remote image handling for activity avatars and extension page CSP rules + ## [1.0.2] - 2025-11-19 ### Fixed diff --git a/PRIVACY.md b/PRIVACY.md index 021aedb..52a741e 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -14,8 +14,8 @@ GitHub Devwatch collects and stores the following data **locally on your device 1. **GitHub OAuth Session** - Created when you connect GitHub through the built-in device-flow sign-in - - Stored by the extension in Chrome storage - - Current builds encrypt the auth session before writing it to local storage and keep a decrypted session copy while the extension is running + - Stored by the extension in Chrome session storage for the current browser session only + - Cleared when the browser session ends or when you disconnect GitHub in DevWatch - Used only to authenticate with GitHub's API - Not sent to third-party services operated by this project - Never shared with anyone @@ -56,7 +56,7 @@ All data collected is used exclusively to provide the extension's functionality: - The extension uses Chrome storage APIs for settings, cached activity, and GitHub sign-in handling - Settings and repository lists can optionally sync across your Chrome browsers if you use Chrome Sync -- GitHub sign-in data uses local and session storage rather than Chrome sync +- GitHub sign-in data uses session storage rather than Chrome sync and is not persisted across browser restarts - You can clear all data at any time by uninstalling the extension or using Chrome's "Clear extension data" feature ## Third-Party Services @@ -112,7 +112,7 @@ You have complete control over your data: Current builds include several concrete safeguards: - All API requests use HTTPS -- The GitHub auth session is encrypted before it is persisted locally +- The GitHub auth session is kept in session storage for the current browser session only - The codebase includes input sanitization and GitHub URL validation checks - Extension pages use a Content Security Policy diff --git a/SECURITY.md b/SECURITY.md index a8193f9..c8ff207 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -33,8 +33,8 @@ These are better suited for regular issues: The extension includes several concrete protections, but this project has not been through a formal external security audit. ### GitHub Sign-In Storage -- GitHub auth sessions are encrypted before they are written to local extension storage -- A decrypted copy may be cached in session storage while the extension is running +- GitHub auth sessions are kept in `chrome.storage.session` for the current browser session only +- Legacy on-disk auth storage is cleared by current builds during sign-in handling - Never transmitted to third-party servers ### Content Security Policy diff --git a/manifest.json b/manifest.json index 1c7478f..a85d343 100644 --- a/manifest.json +++ b/manifest.json @@ -34,6 +34,6 @@ "128": "icons/icon128.png" }, "content_security_policy": { - "extension_pages": "script-src 'self'; object-src 'self'; connect-src 'self' https://api.github.com https://github.com https://registry.npmjs.org; img-src 'self' https: data:; default-src 'self'; style-src 'self'" + "extension_pages": "script-src 'self'; object-src 'self'; connect-src 'self' https://api.github.com https://github.com https://registry.npmjs.org; img-src 'self' data: https://github.com https://*.github.com https://githubusercontent.com https://*.githubusercontent.com; default-src 'self'; style-src 'self'" } } diff --git a/options/options.html b/options/options.html index e6471d8..570ba9d 100644 --- a/options/options.html +++ b/options/options.html @@ -94,7 +94,7 @@

Connect GitHub

- Security: Your GitHub sign-in session is encrypted with AES-GCM and stored locally on your device. + Security: Your GitHub sign-in stays in Chrome session storage and is cleared when the browser session ends.

diff --git a/options/options.js b/options/options.js index 808da7d..5ce7340 100644 --- a/options/options.js +++ b/options/options.js @@ -1,5 +1,5 @@ import { applyTheme, formatDateVerbose } from '../shared/utils.js'; -import { getAuthSession, getAccessToken, getLocalItems, getWatchedRepos, setLocalItem, setWatchedRepos } from '../shared/storage-helpers.js'; +import { clearAuthSession, getAuthSession, getAccessToken, getLocalItems, getWatchedRepos, setLocalItem, setWatchedRepos } from '../shared/storage-helpers.js'; import { createHeaders } from '../shared/github-api.js'; import { STORAGE_CONFIG, VALIDATION_PATTERNS } from '../shared/config.js'; import { validateRepository } from '../shared/repository-validator.js'; @@ -1052,6 +1052,7 @@ async function resetSettings() { try { // Clear all storage (both sync and local) + await clearAuthSession(); await chrome.storage.sync.clear(); await chrome.storage.local.clear(); diff --git a/popup/views/onboarding-view.js b/popup/views/onboarding-view.js index 11316e7..19a747a 100644 --- a/popup/views/onboarding-view.js +++ b/popup/views/onboarding-view.js @@ -379,7 +379,7 @@ async function renderTokenStep() { - Your GitHub sign-in session is encrypted with AES-GCM encryption and stored securely on your device. It's only used for GitHub API access and never shared. + Your GitHub sign-in stays in Chrome session storage for the current browser session only. It's used only for GitHub API access and is cleared when the browser session ends.

`; } diff --git a/shared/auth.js b/shared/auth.js index 7cf070d..379ec2f 100644 --- a/shared/auth.js +++ b/shared/auth.js @@ -1,4 +1,5 @@ import { API_CONFIG, OAUTH_CONFIG } from './config.js'; +import { isValidGitHubAuthUrl } from './security.js'; function getStorageValue(area, key) { return new Promise((resolve) => { @@ -181,9 +182,18 @@ export async function requestGitHubDeviceCode() { export function openGitHubDevicePage(deviceCodeData) { const targetUrl = deviceCodeData.verificationUriComplete || deviceCodeData.verificationUri || OAUTH_CONFIG.DEVICE_VERIFY_URL; + if (!isValidGitHubAuthUrl(targetUrl)) { + throw createOAuthError( + 'GitHub sign-in returned an unexpected verification URL.', + 'invalid_verification_url' + ); + } + if (chrome?.tabs?.create) { chrome.tabs.create({ url: targetUrl }); } + + return targetUrl; } export async function pollForGitHubAccessToken(deviceCodeData, options = {}) { diff --git a/shared/crypto-utils.js b/shared/crypto-utils.js deleted file mode 100644 index 5665577..0000000 --- a/shared/crypto-utils.js +++ /dev/null @@ -1,106 +0,0 @@ -/** - * Cryptographic utilities for secure auth session storage - * Uses Web Crypto API (AES-GCM) - */ - -const ALGORITHM = 'AES-GCM'; -const KEY_USAGE = ['encrypt', 'decrypt']; -const KEY_STORAGE_KEY = 'encryptionKey'; - -/** - * Get or create the encryption key - * Persists the key in chrome.storage.local so it survives restarts - * @returns {Promise} The encryption key - */ -async function getEncryptionKey() { - // Try to get existing key from storage - const stored = await new Promise((resolve) => { - chrome.storage.local.get([KEY_STORAGE_KEY], (result) => { - resolve(result[KEY_STORAGE_KEY]); - }); - }); - - if (stored) { - // Import the stored key - const keyData = new Uint8Array(stored); - return await crypto.subtle.importKey( - 'raw', - keyData, - ALGORITHM, - true, - KEY_USAGE - ); - } - - // Generate a new key - const key = await crypto.subtle.generateKey( - { name: ALGORITHM, length: 256 }, - true, - KEY_USAGE - ); - - // Export and store the new key - const exported = await crypto.subtle.exportKey('raw', key); - const keyArray = Array.from(new Uint8Array(exported)); - - await new Promise((resolve) => { - chrome.storage.local.set({ [KEY_STORAGE_KEY]: keyArray }, resolve); - }); - - return key; -} - -/** - * Encrypt string data - * @param {string} data - Data to encrypt - * @returns {Promise} Object containing { iv, data } as arrays - */ -export async function encryptData(data) { - if (!data) return null; - - try { - const key = await getEncryptionKey(); - const iv = crypto.getRandomValues(new Uint8Array(12)); - const encodedData = new TextEncoder().encode(data); - - const encryptedBuffer = await crypto.subtle.encrypt( - { name: ALGORITHM, iv }, - key, - encodedData - ); - - return { - iv: Array.from(iv), - data: Array.from(new Uint8Array(encryptedBuffer)) - }; - } catch (error) { - console.error('Encryption failed:', error); - throw error; - } -} - -/** - * Decrypt data - * @param {Object} encrypted - Object containing { iv, data } as arrays - * @returns {Promise} Decrypted string - */ -export async function decryptData(encrypted) { - if (!encrypted || !encrypted.iv || !encrypted.data) return null; - - try { - const key = await getEncryptionKey(); - const iv = new Uint8Array(encrypted.iv); - const data = new Uint8Array(encrypted.data); - - const decryptedBuffer = await crypto.subtle.decrypt( - { name: ALGORITHM, iv }, - key, - data - ); - - return new TextDecoder().decode(decryptedBuffer); - } catch (error) { - console.error('Decryption failed:', error); - return null; - } -} diff --git a/shared/dom-optimizer.js b/shared/dom-optimizer.js index a85ff24..73a1ae5 100644 --- a/shared/dom-optimizer.js +++ b/shared/dom-optimizer.js @@ -5,6 +5,7 @@ import { UI_CONFIG } from './config.js'; import { SNOOZE_ICON, CHECK_ICON } from './icons.js'; +import { sanitizeImageUrl } from './sanitize.js'; /** * Basic DOM renderer with simple caching to avoid unnecessary re-renders @@ -440,7 +441,8 @@ class ActivityListRenderer { const sanitizedType = escapeHtml(activity.type); const sanitizedTypeLabel = escapeHtml(this.getActivityTypeLabel(activity.type)); const sanitizedDescription = activity.description ? escapeHtml(activity.description) : ''; - const sanitizedAvatar = activity.authorAvatar || 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg"%3E%3C/svg%3E'; + const sanitizedAvatar = sanitizeImageUrl(activity.authorAvatar) + || 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg"%3E%3C/svg%3E'; const sanitizedUrl = escapeHtml(activity.url); const sanitizedId = escapeHtml(activity.id); @@ -576,4 +578,4 @@ function escapeHtml(text) { } // Export classes and utilities -export { DOMOptimizer, ActivityListRenderer, escapeHtml }; \ No newline at end of file +export { DOMOptimizer, ActivityListRenderer, escapeHtml }; diff --git a/shared/security.js b/shared/security.js index bdce22a..8513614 100644 --- a/shared/security.js +++ b/shared/security.js @@ -38,6 +38,28 @@ export function isValidGitHubUrl(url) { } } +/** + * Validates the GitHub device flow verification page before opening it. + * Restricts auth navigation to the expected GitHub login path. + * @param {string} url - URL to validate + * @returns {boolean} - True if URL is safe for the OAuth device flow + */ +export function isValidGitHubAuthUrl(url) { + if (!url || typeof url !== 'string') { + return false; + } + + try { + const parsed = new URL(url); + + return parsed.protocol === 'https:' + && parsed.hostname === 'github.com' + && parsed.pathname === '/login/device'; + } catch { + return false; + } +} + /** * Safely opens a URL in a new tab only if it's a valid GitHub URL * @param {string} url - URL to open diff --git a/shared/storage-helpers.js b/shared/storage-helpers.js index ada59b5..0d25925 100644 --- a/shared/storage-helpers.js +++ b/shared/storage-helpers.js @@ -1,11 +1,11 @@ /** * Chrome storage helper functions with promisified APIs */ -import { encryptData, decryptData } from './crypto-utils.js'; const AUTH_SESSION_CACHE_KEY = 'githubAuthSession'; -const AUTH_SESSION_STORAGE_KEY = 'encryptedGithubAuthSession'; +const LEGACY_AUTH_STORAGE_KEYS = ['encryptedGithubAuthSession', 'encryptionKey']; const WATCHED_REPOS_STORAGE_KEY = 'watchedRepos'; +let legacyAuthStorageChecked = false; /** * Check if running in Chrome extension context @@ -15,6 +15,28 @@ function isChromeExtension() { return typeof chrome !== 'undefined' && chrome.storage !== undefined; } +function clearLegacyAuthStorage(force = false) { + if (!isChromeExtension()) { + return Promise.resolve(); + } + + if (legacyAuthStorageChecked && !force) { + return Promise.resolve(); + } + + if (!chrome.storage.local?.remove) { + legacyAuthStorageChecked = true; + return Promise.resolve(); + } + + return new Promise((resolve) => { + chrome.storage.local.remove(LEGACY_AUTH_STORAGE_KEYS, () => { + legacyAuthStorageChecked = true; + resolve(); + }); + }); +} + /** * Get an item from chrome.storage.sync with Promise API * @param {string} key - Storage key @@ -213,7 +235,8 @@ export function getExcludedRepos(mutedRepos = [], snoozedRepos = []) { /** * Get the stored GitHub auth session - * Tries session storage first (decrypted cache), then local storage (encrypted) + * Auth sessions are kept in session storage only so they do not persist to disk. + * Legacy encrypted local storage is cleared opportunistically on access. * @returns {Promise} Auth session or null */ export async function getAuthSession() { @@ -223,33 +246,13 @@ export async function getAuthSession() { }); if (cachedSession && typeof cachedSession === 'object') { + await clearLegacyAuthStorage(); return cachedSession; } } - const encrypted = await getLocalItem(AUTH_SESSION_STORAGE_KEY); - if (!encrypted) { - return null; - } - - try { - const decrypted = await decryptData(encrypted); - const session = typeof decrypted === 'string' ? JSON.parse(decrypted) : decrypted; - - if (!session || typeof session !== 'object') { - return null; - } - - if (isChromeExtension() && chrome.storage.session) { - await new Promise(resolve => { - chrome.storage.session.set({ [AUTH_SESSION_CACHE_KEY]: session }, resolve); - }); - } - - return session; - } catch (_error) { - return null; - } + await clearLegacyAuthStorage(); + return null; } /** @@ -263,14 +266,14 @@ export async function setAuthSession(session) { return; } - if (isChromeExtension() && chrome.storage.session) { - await new Promise(resolve => { - chrome.storage.session.set({ [AUTH_SESSION_CACHE_KEY]: session }, resolve); - }); + if (!isChromeExtension() || !chrome.storage.session) { + throw new Error('Session storage is unavailable for GitHub sign-in.'); } - const encrypted = await encryptData(JSON.stringify(session)); - await setLocalItem(AUTH_SESSION_STORAGE_KEY, encrypted); + await new Promise(resolve => { + chrome.storage.session.set({ [AUTH_SESSION_CACHE_KEY]: session }, resolve); + }); + await clearLegacyAuthStorage(true); } /** @@ -284,7 +287,7 @@ export async function clearAuthSession() { }); } - await setLocalItem(AUTH_SESSION_STORAGE_KEY, null); + await clearLegacyAuthStorage(true); } /** diff --git a/tests/auth.test.js b/tests/auth.test.js index 8a7aa1e..2c0c151 100644 --- a/tests/auth.test.js +++ b/tests/auth.test.js @@ -86,6 +86,15 @@ describe('GitHub OAuth helpers', () => { }); }); + it('rejects unexpected verification URLs', async () => { + const { openGitHubDevicePage } = await import('../shared/auth.js'); + + expect(() => openGitHubDevicePage({ + verificationUri: 'https://evil.example/login/device' + })).toThrow('unexpected verification URL'); + expect(mockCreateTab).not.toHaveBeenCalled(); + }); + it('polls until the user approves sign-in', async () => { const { pollForGitHubAccessToken } = await import('../shared/auth.js'); diff --git a/tests/dom-optimizer.test.js b/tests/dom-optimizer.test.js index d15237a..f3d4f65 100644 --- a/tests/dom-optimizer.test.js +++ b/tests/dom-optimizer.test.js @@ -499,6 +499,22 @@ describe('ActivityListRenderer', () => { expect(html1).toBe(html2); expect(renderer.itemCache.has('125-false')).toBe(true); }); + + test('falls back to a safe placeholder avatar for invalid image URLs', () => { + const activity = { + id: '126', + type: 'IssuesEvent', + title: 'Unsafe avatar', + createdAt: new Date().toISOString(), + url: 'https://github.com/test/repo/issues/2', + authorAvatar: 'https://evil.com/avatar.png' + }; + + const html = renderer.generateSingleActivityHTML(activity); + + expect(html).toContain('data:image/svg+xml'); + expect(html).not.toContain('https://evil.com/avatar.png'); + }); }); describe('generateActivityHTML', () => { diff --git a/tests/options-main.test.js b/tests/options-main.test.js index a07af8c..a674721 100644 --- a/tests/options-main.test.js +++ b/tests/options-main.test.js @@ -399,8 +399,8 @@ describe('Options Main Functions', () => { await Promise.resolve(); expect(chrome.storage.session.remove).toHaveBeenCalledWith(['githubAuthSession'], expect.any(Function)); - expect(chrome.storage.local.set).toHaveBeenCalledWith( - expect.objectContaining({ encryptedGithubAuthSession: null }), + expect(chrome.storage.local.remove).toHaveBeenCalledWith( + ['encryptedGithubAuthSession', 'encryptionKey'], expect.any(Function) ); }); diff --git a/tests/options.test.js b/tests/options.test.js index d286728..1f0a1d9 100644 --- a/tests/options.test.js +++ b/tests/options.test.js @@ -12,7 +12,8 @@ global.chrome = { // Always call callback if provided if (callback) callback(); }), - remove: jest.fn(() => { + remove: jest.fn((keys, callback) => { + if (callback) callback(); return Promise.resolve(); }) }, @@ -25,7 +26,8 @@ global.chrome = { // Always call callback if provided if (callback) callback(); }), - remove: jest.fn(() => { + remove: jest.fn((keys, callback) => { + if (callback) callback(); return Promise.resolve(); }) }, @@ -36,7 +38,8 @@ global.chrome = { set: jest.fn((items, callback) => { if (callback) callback(); }), - remove: jest.fn(() => { + remove: jest.fn((keys, callback) => { + if (callback) callback(); return Promise.resolve(); }) } @@ -45,15 +48,6 @@ global.chrome = { // Mock fetch global.fetch = jest.fn(); - - - -// Mock crypto-utils using unstable_mockModule -jest.unstable_mockModule('../shared/crypto-utils.js', () => ({ - encryptData: jest.fn(() => Promise.resolve({ iv: [], data: [] })), - decryptData: jest.fn(() => Promise.resolve('decrypted-token')) -})); - // Import functions dynamically after mocking const { fetchGitHubRepoFromNpm } = await import('../shared/api/npm-api.js'); const { trackRepoUnmuted } = await import('../options/controllers/repository-controller.js'); diff --git a/tests/security.test.js b/tests/security.test.js index 4715c85..1d37b16 100644 --- a/tests/security.test.js +++ b/tests/security.test.js @@ -4,7 +4,7 @@ import { describe, it, expect } from '@jest/globals'; import { escapeHtml, unescapeHtml, sanitizeImageUrl, sanitizeObject, sanitizeActivity, sanitizeRepository } from '../shared/sanitize.js'; -import { isValidGitHubUrl, isValidApiUrl } from '../shared/security.js'; +import { isValidGitHubUrl, isValidGitHubAuthUrl, isValidApiUrl } from '../shared/security.js'; describe('HTML Sanitization', () => { describe('escapeHtml', () => { @@ -459,6 +459,21 @@ describe('HTML Sanitization', () => { }); }); +describe('GitHub URL Validation', () => { + describe('isValidGitHubAuthUrl', () => { + it('allows the expected GitHub device flow URLs', () => { + expect(isValidGitHubAuthUrl('https://github.com/login/device')).toBe(true); + expect(isValidGitHubAuthUrl('https://github.com/login/device?user_code=ABCD-EFGH')).toBe(true); + }); + + it('rejects unexpected hosts or paths', () => { + expect(isValidGitHubAuthUrl('https://gist.github.com/login/device')).toBe(false); + expect(isValidGitHubAuthUrl('https://github.com/settings/applications')).toBe(false); + expect(isValidGitHubAuthUrl('javascript:alert(1)')).toBe(false); + }); + }); +}); + describe('URL Validation', () => { describe('isValidGitHubUrl', () => { it('should accept valid GitHub URLs', () => { diff --git a/tests/storage-helpers.test.js b/tests/storage-helpers.test.js index 3daba37..f5d0807 100644 --- a/tests/storage-helpers.test.js +++ b/tests/storage-helpers.test.js @@ -4,15 +4,6 @@ import { jest } from '@jest/globals'; -// Mock crypto-utils using unstable_mockModule for ESM support -const mockEncryptData = jest.fn(() => Promise.resolve({ iv: [1, 2, 3], data: [4, 5, 6] })); -const mockDecryptData = jest.fn(() => Promise.resolve('decrypted-token')); - -jest.unstable_mockModule('../shared/crypto-utils.js', () => ({ - encryptData: mockEncryptData, - decryptData: mockDecryptData -})); - // Dynamic import after mocking const { getSyncItem, @@ -60,14 +51,12 @@ describe('Storage Helpers', () => { Object.assign(mockSyncStorage, items); if (callback) callback(); }), - remove: jest.fn((keys) => { - return new Promise((resolve) => { - const keyArray = Array.isArray(keys) ? keys : [keys]; - keyArray.forEach(key => { - delete mockSyncStorage[key]; - }); - resolve(); + remove: jest.fn((keys, callback) => { + const keyArray = Array.isArray(keys) ? keys : [keys]; + keyArray.forEach(key => { + delete mockSyncStorage[key]; }); + if (callback) callback(); }) }, local: { @@ -85,14 +74,12 @@ describe('Storage Helpers', () => { Object.assign(mockLocalStorage, items); if (callback) callback(); }), - remove: jest.fn((keys) => { - return new Promise((resolve) => { - const keyArray = Array.isArray(keys) ? keys : [keys]; - keyArray.forEach(key => { - delete mockLocalStorage[key]; - }); - resolve(); + remove: jest.fn((keys, callback) => { + const keyArray = Array.isArray(keys) ? keys : [keys]; + keyArray.forEach(key => { + delete mockLocalStorage[key]; }); + if (callback) callback(); }) }, session: { @@ -388,12 +375,11 @@ describe('Storage Helpers', () => { }); describe('auth session helpers', () => { - it('returns auth session from encrypted local storage', async () => { - mockLocalStorage.encryptedGithubAuthSession = { iv: [], data: [] }; - mockDecryptData.mockResolvedValueOnce(JSON.stringify({ + it('returns auth session from session storage when available', async () => { + mockSessionStorage.githubAuthSession = { accessToken: 'oauth-token', username: 'octocat' - })); + }; const result = await getAuthSession(); @@ -401,28 +387,22 @@ describe('Storage Helpers', () => { accessToken: 'oauth-token', username: 'octocat' }); - expect(mockSessionStorage.githubAuthSession).toEqual({ - accessToken: 'oauth-token', - username: 'octocat' - }); + expect(chrome.storage.local.remove).toHaveBeenCalledWith( + ['encryptedGithubAuthSession', 'encryptionKey'], + expect.any(Function) + ); }); - it('returns auth session from session cache when available', async () => { - mockSessionStorage.githubAuthSession = { - accessToken: 'cached-token', - username: 'cached-user' - }; + it('clears legacy persisted auth data when no session exists', async () => { + mockLocalStorage.encryptedGithubAuthSession = { iv: [1], data: [2] }; + mockLocalStorage.encryptionKey = [3, 4, 5]; const result = await getAuthSession(); - expect(result).toEqual({ - accessToken: 'cached-token', - username: 'cached-user' - }); - expect(mockDecryptData).not.toHaveBeenCalled(); + expect(result).toBeNull(); }); - it('stores auth session in session and encrypted local storage', async () => { + it('stores auth session in session storage only', async () => { await setAuthSession({ accessToken: 'oauth-token', username: 'octocat' @@ -432,21 +412,24 @@ describe('Storage Helpers', () => { accessToken: 'oauth-token', username: 'octocat' }); - expect(mockEncryptData).toHaveBeenCalledWith(JSON.stringify({ - accessToken: 'oauth-token', - username: 'octocat' - })); - expect(mockLocalStorage.encryptedGithubAuthSession).toBeDefined(); + expect(chrome.storage.local.remove).toHaveBeenCalledWith( + ['encryptedGithubAuthSession', 'encryptionKey'], + expect.any(Function) + ); }); - it('clears auth session from all storage', async () => { + it('clears auth session from session storage and removes legacy persisted data', async () => { mockSessionStorage.githubAuthSession = { accessToken: 'oauth-token' }; mockLocalStorage.encryptedGithubAuthSession = { iv: [], data: [] }; + mockLocalStorage.encryptionKey = [1, 2, 3]; await clearAuthSession(); expect(mockSessionStorage.githubAuthSession).toBeUndefined(); - expect(mockLocalStorage.encryptedGithubAuthSession).toBeNull(); + expect(chrome.storage.local.remove).toHaveBeenCalledWith( + ['encryptedGithubAuthSession', 'encryptionKey'], + expect.any(Function) + ); }); it('returns the access token from the current auth session', async () => {