diff --git a/LICENSE-THIRD-PARTY.txt b/LICENSE-THIRD-PARTY.txt index 1064a668..1adb038b 100644 --- a/LICENSE-THIRD-PARTY.txt +++ b/LICENSE-THIRD-PARTY.txt @@ -5235,6 +5235,7 @@ The following npm packages may be included in this product: - csv-stringify@6.6.0 - degenerator@5.0.1 - isarray@1.0.0 + - keychain@1.5.0 - netmask@2.0.2 - tr46@0.0.3 - undici-types@5.26.5 diff --git a/package.json b/package.json index 028b5138..912859c5 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "fs-extra": "^10.1.0", "inquirer": "^8.2.7", "js-yaml": "^4.1.1", + "keychain": "^1.5.0", "keytar": "^7.9.0", "lodash": "^4.17.13", "mkdirp": "^3.0.1", diff --git a/src/box-command.js b/src/box-command.js index 08b28e88..f3fe8527 100644 --- a/src/box-command.js +++ b/src/box-command.js @@ -35,12 +35,7 @@ const pkg = require('../package.json'); const inquirer = require('./inquirer'); const { stringifyStream } = require('@discoveryjs/json-ext'); const progress = require('cli-progress'); -let keytar = null; -try { - keytar = require('keytar'); -} catch { - // keytar cannot be imported because the library is not provided for this operating system / architecture -} +const secureStorage = require('./secure-storage'); const DEBUG = require('./debug'); const stream = require('node:stream'); @@ -96,9 +91,29 @@ const ENVIRONMENTS_FILE_PATH = path.join( CONFIG_FOLDER_PATH, 'box_environments.json' ); +const ENVIRONMENTS_KEYCHAIN_SERVICE = 'boxcli'; +const ENVIRONMENTS_KEYCHAIN_ACCOUNT = 'Box'; const DEFAULT_ANALYTICS_CLIENT_NAME = 'box-cli'; +/** + * Convert error objects to a stable debug-safe shape. + * + * @param {unknown} error A caught error object + * @returns {Object} A reduced object for DEBUG logging + */ +function getDebugErrorDetails(error) { + if (!error || typeof error !== 'object') { + return { message: String(error) }; + } + return { + name: error.name || 'Error', + code: error.code, + message: error.message || String(error), + stack: error.stack, + }; +} + /** * Parse a string value from CSV into the correct boolean value * @param {string|boolean} value The value to parse @@ -300,8 +315,7 @@ class BoxCommand extends Command { this.disableRequiredArgsAndFlags(); } - this.supportsSecureStorage = - keytar && ['darwin', 'win32', 'linux'].includes(process.platform); + this.supportsSecureStorage = secureStorage.available; let { flags, args } = await this.parse(this.constructor); @@ -1809,34 +1823,62 @@ class BoxCommand extends Command { * @returns {Object} The parsed environment information */ async getEnvironments() { - // Try secure storage first on supported platforms if (this.supportsSecureStorage) { + DEBUG.init( + 'Attempting secure storage read via %s service="%s" account="%s"', + secureStorage.backend, + ENVIRONMENTS_KEYCHAIN_SERVICE, + ENVIRONMENTS_KEYCHAIN_ACCOUNT + ); try { - const password = await keytar.getPassword( - 'boxcli' /* service */, - 'Box' /* account */ + const password = await secureStorage.getPassword( + ENVIRONMENTS_KEYCHAIN_SERVICE, + ENVIRONMENTS_KEYCHAIN_ACCOUNT ); if (password) { + DEBUG.init( + 'Successfully loaded environments from secure storage (%s)', + secureStorage.backend + ); return JSON.parse(password); } + DEBUG.init( + 'Secure storage returned empty result for service="%s" account="%s"', + ENVIRONMENTS_KEYCHAIN_SERVICE, + ENVIRONMENTS_KEYCHAIN_ACCOUNT + ); } catch (error) { DEBUG.init( - 'Failed to read from secure storage, falling back to file: %s', - error.message + 'Failed to read from secure storage (%s), falling back to file: %O', + secureStorage.backend, + getDebugErrorDetails(error) ); - // fallback to env file } + } else { + DEBUG.init( + 'Skipping secure storage read: platform=%s available=%s', + process.platform, + secureStorage.available + ); } // Try to read from file (fallback or no secure storage) try { if (fs.existsSync(ENVIRONMENTS_FILE_PATH)) { + DEBUG.init( + 'Attempting environments fallback file read at %s', + ENVIRONMENTS_FILE_PATH + ); return JSON.parse(fs.readFileSync(ENVIRONMENTS_FILE_PATH)); } + DEBUG.init( + 'Environments fallback file does not exist at %s', + ENVIRONMENTS_FILE_PATH + ); } catch (error) { DEBUG.init( - 'Failed to read environments from file: %s', - error.message + 'Failed to read environments from file: %O', + getDebugErrorDetails(error) ); } @@ -1861,32 +1903,43 @@ class BoxCommand extends Command { let storedInSecureStorage = false; - // Try secure storage first on supported platforms if (this.supportsSecureStorage) { + DEBUG.init( + 'Attempting secure storage write via %s service="%s" account="%s"', + secureStorage.backend, + ENVIRONMENTS_KEYCHAIN_SERVICE, + ENVIRONMENTS_KEYCHAIN_ACCOUNT + ); try { - await keytar.setPassword( - 'boxcli' /* service */, - 'Box' /* account */, - JSON.stringify(environments) /* password */ + await secureStorage.setPassword( + ENVIRONMENTS_KEYCHAIN_SERVICE, + ENVIRONMENTS_KEYCHAIN_ACCOUNT, + JSON.stringify(environments) ); storedInSecureStorage = true; DEBUG.init( - 'Stored environment configuration in secure storage' + 'Stored environment configuration in secure storage (%s)', + secureStorage.backend ); - // Successfully stored in secure storage, remove the file if (fs.existsSync(ENVIRONMENTS_FILE_PATH)) { fs.unlinkSync(ENVIRONMENTS_FILE_PATH); DEBUG.init( 'Removed environment configuration file after migrating to secure storage' ); } - } catch (keytarError) { - // fallback to file storage if secure storage fails + } catch (error) { DEBUG.init( - 'Could not store credentials in secure storage, falling back to file: %s', - keytarError.message + 'Could not store credentials in secure storage (%s), falling back to file: %O', + secureStorage.backend, + getDebugErrorDetails(error) ); } + } else { + DEBUG.init( + 'Skipping secure storage write: platform=%s available=%s', + process.platform, + secureStorage.available + ); } // Write to file if secure storage failed or not available @@ -1895,13 +1948,10 @@ class BoxCommand extends Command { let fileContents = JSON.stringify(environments, null, 4); fs.writeFileSync(ENVIRONMENTS_FILE_PATH, fileContents, 'utf8'); - // Show warning to user if secure storage was attempted but failed - if (this.supportsSecureStorage) { + if (process.platform === 'linux' && this.supportsSecureStorage) { this.info( - `Could not store credentials in secure storage, falling back to file.` + - (process.platform === 'linux' - ? ' To enable secure storage on Linux, install libsecret-1-dev package.' - : '') + 'Could not store credentials in secure storage, falling back to file.' + + ' To enable secure storage on Linux, install libsecret-1-dev package.' ); } } catch (error) { diff --git a/src/secure-storage.js b/src/secure-storage.js new file mode 100644 index 00000000..46bdb6a5 --- /dev/null +++ b/src/secure-storage.js @@ -0,0 +1,196 @@ +'use strict'; + +const { promisify } = require('node:util'); +const DEBUG = require('./debug'); +const PLATFORM_DARWIN = 'darwin'; +const KEYTAR = 'keytar'; +const KEYCHAIN = 'keychain'; + +/** + * Load an optional dependency and capture load errors. + * + * @param {string} packageName Package to load + * @param {boolean} shouldLoad Whether this package should be loaded + * @returns {{ loadedModule: unknown, loadError: unknown }} Result of loading + */ +function loadOptionalModule(packageName, shouldLoad = true) { + if (!shouldLoad) { + return { loadedModule: null, loadError: null }; + } + try { + return { loadedModule: require(packageName), loadError: null }; + } catch (error) { + return { loadedModule: null, loadError: error }; + } +} + +const { loadedModule: keytarModule, loadError: keytarLoadError } = + loadOptionalModule(KEYTAR, process.platform !== PLATFORM_DARWIN); +const { loadedModule: keychainModule, loadError: keychainLoadError } = + loadOptionalModule(KEYCHAIN, process.platform === PLATFORM_DARWIN); + +const isDarwin = process.platform === PLATFORM_DARWIN; +const SUPPORTED_SECURE_STORAGE_PLATFORMS = [PLATFORM_DARWIN, 'win32', 'linux']; +const isSecurePlatform = SUPPORTED_SECURE_STORAGE_PLATFORMS.includes( + process.platform +); + +/** + * Returns true when error indicates missing keychain/keytar entry. + * + * @param {unknown} error The caught error + * @returns {boolean} Whether this is a "secret not found" error + */ +function isSecretNotFoundError(error) { + const message = String(error?.message || '').toLowerCase(); + return ( + error?.code === 'ENOENT' || + message.includes('not found') || + message.includes('password not found') || + message.includes('item not found') || + message.includes('could not find password') + ); +} + +/** + * Unified secure storage wrapper. + * + * On macOS uses the `keychain` npm module (which wraps `/usr/bin/security`). + * ACL (Access Control List) in Keychain is a per-secret allowlist of apps + * that can access the item without prompting. Using `keychain` avoids ACL + * prompts because the accessing process is always the stable system + * `security` binary, regardless of CLI binary identity/signature changes. + * If we used `keytar` on macOS, access would come from the current + * `node`/CLI executable identity; after signed-binary upgrades, macOS can + * treat it as a different app and show ACL prompts for existing items. + * That is why this module intentionally does not use `keytar` on macOS. + * + * On Windows/Linux uses `keytar` (native Keychain/Credential Vault/libsecret). + */ +class SecureStorage { + constructor() { + if (isDarwin && keychainModule) { + this.backend = KEYCHAIN; + this.available = true; + } else if (!isDarwin && isSecurePlatform && keytarModule) { + this.backend = KEYTAR; + this.available = true; + } else { + this.backend = null; + this.available = false; + } + + DEBUG.init('Secure storage initialized %O', { + platform: process.platform, + arch: process.arch, + backend: this.backend, + available: this.available, + keytarLoaded: Boolean(keytarModule), + darwinKeychainLoaded: Boolean(keychainModule), + }); + + if (!this.available) { + if (isDarwin && !keychainModule) { + DEBUG.init( + 'macOS keychain module not available: %s', + keychainLoadError?.message || 'unknown' + ); + } + if (!isDarwin && !keytarModule) { + DEBUG.init( + 'keytar module not available: %s', + keytarLoadError?.message || 'unknown' + ); + } + } + } + + /** + * Read a password from secure storage. + * + * @param {string} service The service name + * @param {string} account The account name + * @returns {Promise} The stored password, or null + */ + async getPassword(service, account) { + if (!this.available) { + return null; + } + + if (this.backend === KEYCHAIN) { + try { + const getPasswordAsync = promisify( + keychainModule.getPassword.bind(keychainModule) + ); + const password = await getPasswordAsync({ + account, + service, + }); + return password || null; + } catch (error) { + if (isSecretNotFoundError(error)) { + return null; + } + throw error; + } + } + + return keytarModule.getPassword(service, account); + } + + /** + * Write a password to secure storage. + * + * @param {string} service The service name + * @param {string} account The account name + * @param {string} password The value to store + * @returns {Promise} + */ + async setPassword(service, account, password) { + if (!this.available) { + throw new Error('Secure storage is not available'); + } + + if (this.backend === KEYCHAIN) { + const setPasswordAsync = promisify( + keychainModule.setPassword.bind(keychainModule) + ); + await setPasswordAsync({ account, service, password }); + return; + } + + await keytarModule.setPassword(service, account, password); + } + + /** + * Delete a password from secure storage. + * + * @param {string} service The service name + * @param {string} account The account name + * @returns {Promise} true if deleted + */ + async deletePassword(service, account) { + if (!this.available) { + return false; + } + + if (this.backend === KEYCHAIN) { + try { + const deletePasswordAsync = promisify( + keychainModule.deletePassword.bind(keychainModule) + ); + await deletePasswordAsync({ account, service }); + return true; + } catch (error) { + if (isSecretNotFoundError(error)) { + return false; + } + throw error; + } + } + + return keytarModule.deletePassword(service, account); + } +} + +module.exports = new SecureStorage(); diff --git a/src/token-cache.js b/src/token-cache.js index 71a209ae..a4b7cac8 100644 --- a/src/token-cache.js +++ b/src/token-cache.js @@ -8,17 +8,11 @@ const path = require('node:path'); const BoxCLIError = require('./cli-error'); const utilities = require('./util'); const DEBUG = require('./debug'); - -let keytar = null; -try { - keytar = require('keytar'); -} catch { - // keytar cannot be imported because the library is not provided for this operating system / architecture -} +const secureStorage = require('./secure-storage'); /** * Cache interface used by the Node SDK to cache tokens to disk in the user's home directory - * Supports secure storage via keytar with fallback to file system + * Supports secure storage with fallback to file system */ class CLITokenCache { /** @@ -27,16 +21,15 @@ class CLITokenCache { */ constructor(environmentName) { this.environmentName = environmentName; + this.secureStorage = secureStorage; this.filePath = path.join( os.homedir(), '.box', `${environmentName}_token_cache.json` ); - // Service and account for keytar - includes environment name for multiple environments - this.keytarService = `boxcli-token-${environmentName}`; - this.keytarAccount = 'Box'; - this.supportsSecureStorage = - keytar && ['darwin', 'win32', 'linux'].includes(process.platform); + this.serviceName = `boxcli-token-${environmentName}`; + this.accountName = 'Box'; + this.supportsSecureStorage = this.secureStorage.available; } /** @@ -45,16 +38,16 @@ class CLITokenCache { * @returns {void} */ read(callback) { - // Try secure storage first if available if (this.supportsSecureStorage) { - keytar - .getPassword(this.keytarService, this.keytarAccount) + this.secureStorage + .getPassword(this.serviceName, this.accountName) .then((tokenJson) => { if (tokenJson) { try { const tokenInfo = JSON.parse(tokenJson); DEBUG.init( - 'Loaded token from secure storage for environment: %s', + 'Loaded token from secure storage (%s) for environment: %s', + this.secureStorage.backend, this.environmentName ); return callback(null, tokenInfo); @@ -63,22 +56,27 @@ class CLITokenCache { 'Failed to parse token from secure storage, falling back to file: %s', parseError.message ); - // Fall through to file-based storage } } - // Token not in secure storage, try file + DEBUG.init( + 'No token found in secure storage for environment: %s; trying file cache', + this.environmentName + ); return this._readFromFile(callback); }) .catch((error) => { DEBUG.init( - 'Failed to read from secure storage, falling back to file: %s', - error.message + 'Failed to read from secure storage (%s), falling back to file: %s', + this.secureStorage.backend, + error?.message || error ); - // Fall back to file-based storage this._readFromFile(callback); }); } else { - // Secure storage not available, use file + DEBUG.init( + 'Secure storage unavailable for token cache; reading token from file for environment: %s', + this.environmentName + ); this._readFromFile(callback); } } @@ -116,44 +114,43 @@ class CLITokenCache { write(tokenInfo, callback) { const output = JSON.stringify(tokenInfo, null, 4); - // Try secure storage first if available if (this.supportsSecureStorage) { - keytar - .setPassword(this.keytarService, this.keytarAccount, output) + this.secureStorage + .setPassword(this.serviceName, this.accountName, output) .then(() => { DEBUG.init( - 'Stored token in secure storage for environment: %s', + 'Stored token in secure storage (%s) for environment: %s', + this.secureStorage.backend, this.environmentName ); - // Clear the file-based cache if it exists (migration scenario) if (fs.existsSync(this.filePath)) { fs.unlinkSync(this.filePath); + DEBUG.init( + 'Migrated token from file to secure storage for environment: %s', + this.environmentName + ); } - return; - }) - .then(() => { - DEBUG.init( - 'Migrated token from file to secure storage for environment: %s', - this.environmentName - ); return callback(); }) .catch((error) => { DEBUG.init( - 'Failed to write to secure storage for environment %s, falling back to file: %s', + 'Failed to write to secure storage (%s) for environment %s, falling back to file: %s', + this.secureStorage.backend, this.environmentName, - error.message + error?.message || error ); if (process.platform === 'linux') { DEBUG.init( 'To enable secure storage on Linux, install libsecret-1-dev package' ); } - // Fall back to file-based storage this._writeToFile(output, callback); }); } else { - // Secure storage not available, use file + DEBUG.init( + 'Secure storage unavailable for token cache; writing token to file for environment: %s', + this.environmentName + ); this._writeToFile(output, callback); } } @@ -185,11 +182,10 @@ class CLITokenCache { clear(callback) { const promises = []; - // Try to delete from secure storage if (this.supportsSecureStorage) { promises.push( - keytar - .deletePassword(this.keytarService, this.keytarAccount) + this.secureStorage + .deletePassword(this.serviceName, this.accountName) .then((deleted) => { if (!deleted) { DEBUG.init( @@ -226,10 +222,9 @@ class CLITokenCache { ); } - // Try to delete from file promises.push( utilities.unlinkAsync(this.filePath).catch((error) => { - if (error && error.code === 'ENOENT') { + if (error?.code === 'ENOENT') { DEBUG.init( 'No token file found on disk for environment: %s', this.environmentName @@ -254,12 +249,12 @@ class CLITokenCache { */ store(token) { return new Promise((resolve, reject) => { - const accquiredAtMS = Date.now(); + const acquiredAtMS = Date.now(); const tokenInfo = { accessToken: token.accessToken, accessTokenTTLMS: token.expiresIn * 1000, refreshToken: token.refreshToken, - acquiredAtMS: accquiredAtMS, + acquiredAtMS, }; this.write(tokenInfo, (error) => { if (error) { diff --git a/test/secure-storage.test.js b/test/secure-storage.test.js new file mode 100644 index 00000000..610ac5bd --- /dev/null +++ b/test/secure-storage.test.js @@ -0,0 +1,137 @@ +'use strict'; + +const { assert } = require('chai'); +const sinon = require('sinon'); + +describe('SecureStorage', function () { + const sandbox = sinon.createSandbox(); + let secureStorage; + + afterEach(function () { + sandbox.verifyAndRestore(); + delete require.cache[require.resolve('../src/secure-storage')]; + }); + + describe('on macOS (darwin)', function () { + beforeEach(function () { + if (process.platform !== 'darwin') { + this.skip(); + } + secureStorage = require('../src/secure-storage'); + }); + + it('should use keychain backend on macOS', function () { + assert.equal(secureStorage.backend, 'keychain'); + assert.isTrue(secureStorage.available); + }); + + it('getPassword should delegate to keychain module', async function () { + const kc = require('keychain'); + const stub = sandbox + .stub(kc, 'getPassword') + .callsFake((opts, fn) => fn(null, 'stored-value')); + + const password = await secureStorage.getPassword('boxcli', 'Box'); + + assert.equal(password, 'stored-value'); + assert.isTrue(stub.calledOnce); + assert.deepEqual(stub.firstCall.args[0], { + account: 'Box', + service: 'boxcli', + }); + }); + + it('getPassword should return null when not found', async function () { + const kc = require('keychain'); + sandbox + .stub(kc, 'getPassword') + .callsFake((opts, fn) => fn(new Error('not found'), null)); + + const password = await secureStorage.getPassword('boxcli', 'Box'); + + assert.isNull(password); + }); + + it('setPassword should delegate to keychain module', async function () { + const kc = require('keychain'); + const stub = sandbox + .stub(kc, 'setPassword') + .callsFake((opts, fn) => fn(null)); + + await secureStorage.setPassword('boxcli', 'Box', 'secret'); + + assert.isTrue(stub.calledOnce); + assert.deepEqual(stub.firstCall.args[0], { + account: 'Box', + service: 'boxcli', + password: 'secret', + }); + }); + + it('deletePassword should delegate to keychain module', async function () { + const kc = require('keychain'); + const stub = sandbox + .stub(kc, 'deletePassword') + .callsFake((opts, fn) => fn(null)); + + const result = await secureStorage.deletePassword('boxcli', 'Box'); + + assert.isTrue(result); + assert.isTrue(stub.calledOnce); + assert.deepEqual(stub.firstCall.args[0], { + account: 'Box', + service: 'boxcli', + }); + }); + + it('deletePassword should return false when not found', async function () { + const kc = require('keychain'); + sandbox + .stub(kc, 'deletePassword') + .callsFake((opts, fn) => + fn(new Error('Could not find password')) + ); + + const result = await secureStorage.deletePassword('boxcli', 'Box'); + + assert.isFalse(result); + }); + }); + + describe('availability checks', function () { + it('should report available=true on supported platform with backend', function () { + secureStorage = require('../src/secure-storage'); + if (secureStorage.backend) { + assert.isTrue(secureStorage.available); + } + }); + + it('getPassword should return null when not available', async function () { + secureStorage = require('../src/secure-storage'); + sandbox.stub(secureStorage, 'available').value(false); + + const password = await secureStorage.getPassword('boxcli', 'Box'); + assert.isNull(password); + }); + + it('setPassword should throw when not available', async function () { + secureStorage = require('../src/secure-storage'); + sandbox.stub(secureStorage, 'available').value(false); + + try { + await secureStorage.setPassword('boxcli', 'Box', 'secret'); + assert.fail('Should have thrown'); + } catch (error) { + assert.include(error.message, 'not available'); + } + }); + + it('deletePassword should return false when not available', async function () { + secureStorage = require('../src/secure-storage'); + sandbox.stub(secureStorage, 'available').value(false); + + const result = await secureStorage.deletePassword('boxcli', 'Box'); + assert.isFalse(result); + }); + }); +}); diff --git a/test/token-cache.test.js b/test/token-cache.test.js index 521ce561..22b54821 100644 --- a/test/token-cache.test.js +++ b/test/token-cache.test.js @@ -55,27 +55,20 @@ describe('CLITokenCache', function () { expect(tokenCache.filePath).to.equal(testFilePath); }); - it('should set correct keytar service name', function () { - expect(tokenCache.keytarService).to.equal( + it('should set correct secure storage service name', function () { + expect(tokenCache.serviceName).to.equal( `boxcli-token-${testEnvName}` ); }); - it('should set keytar account name', function () { - expect(tokenCache.keytarAccount).to.equal('Box'); + it('should set secure storage account name', function () { + expect(tokenCache.accountName).to.equal('Box'); }); it('should detect secure storage support on supported platforms', function () { - let keytar = null; - try { - keytar = require('keytar'); - } catch { - // keytar cannot be imported because the library is not provided for this operating system / architecture - } - const supportedPlatforms = ['darwin', 'win32', 'linux']; - const isSupportedOS = supportedPlatforms.includes(process.platform); + const secureStorage = require('../src/secure-storage'); expect(tokenCache.supportsSecureStorage).to.equal( - keytar && isSupportedOS + secureStorage.available ); }); }); @@ -344,12 +337,12 @@ describe('CLITokenCache', function () { }); }); - it('should use different keytar service names for different environments', function () { + it('should use different service names for different environments', function () { const env1Cache = new CLITokenCache('production'); const env2Cache = new CLITokenCache('development'); - expect(env1Cache.keytarService).to.equal('boxcli-token-production'); - expect(env2Cache.keytarService).to.equal( + expect(env1Cache.serviceName).to.equal('boxcli-token-production'); + expect(env2Cache.serviceName).to.equal( 'boxcli-token-development' ); }); @@ -401,9 +394,8 @@ describe('CLITokenCache', function () { } const unlinkStub = sinon.stub(utilities, 'unlinkAsync').resolves(); - const keytar = require('keytar'); const deletePasswordStub = sinon - .stub(keytar, 'deletePassword') + .stub(tokenCache.secureStorage, 'deletePassword') .rejects( Object.assign(new Error('Permission denied'), { code: 'EACCES', @@ -428,15 +420,12 @@ describe('CLITokenCache', function () { this.skip(); } - // Mock keytar to simulate failure - const keytar = require('keytar'); const setPasswordStub = sinon - .stub(keytar, 'setPassword') + .stub(tokenCache.secureStorage, 'setPassword') .rejects(new Error('Secure storage unavailable')); tokenCache.write(testTokenInfo, (error) => { expect(error).to.be.undefined; - // Should fallback to file expect(fs.existsSync(testFilePath)).to.be.true; setPasswordStub.restore(); @@ -449,7 +438,6 @@ describe('CLITokenCache', function () { this.skip(); } - // Create a file-based token const boxDir = path.join(os.homedir(), '.box'); if (!fs.existsSync(boxDir)) { fs.mkdirSync(boxDir, { recursive: true }); @@ -460,10 +448,8 @@ describe('CLITokenCache', function () { 'utf8' ); - // Mock keytar to simulate failure - const keytar = require('keytar'); const getPasswordStub = sinon - .stub(keytar, 'getPassword') + .stub(tokenCache.secureStorage, 'getPassword') .rejects(new Error('Secure storage unavailable')); tokenCache.read((error, tokenInfo) => {