From d29e3257ae9c0c47ad7f85b7473920230d4390f8 Mon Sep 17 00:00:00 2001 From: Waldek Mastykarz Date: Tue, 9 Jun 2026 15:51:48 +0200 Subject: [PATCH 1/8] Replace custom MSAL cache plugin with msal-node-extensions Replace the custom ICachePlugin implementation that stored tokens as plain JSON files (~/.cli-m365-msal.json) with @azure/msal-node-extensions PersistenceCachePlugin, which stores tokens in the OS-specific credential store (macOS Keychain, Windows DPAPI, Linux libsecret with plaintext fallback). Key changes: - msalCachePlugin.ts: replaced custom beforeCacheAccess/afterCacheAccess with PersistenceCreator.createPersistence() and PersistenceCachePlugin - Auth.ts: use async getCachePlugin() and clearMsalCache() from the new module - FileTokenStorage.ts: removed msalCacheFilePath() (no longer needed for MSAL cache; still used for connection info storage) - Updated all tests to use the new stubbable object pattern Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- npm-shrinkwrap.json | 357 ++++++++++++++++++++++++++++++ package.json | 1 + src/Auth.spec.ts | 18 +- src/Auth.ts | 8 +- src/auth/FileTokenStorage.spec.ts | 6 - src/auth/FileTokenStorage.ts | 4 - src/auth/msalCachePlugin.spec.ts | 109 +++++---- src/auth/msalCachePlugin.ts | 75 ++++--- 8 files changed, 477 insertions(+), 101 deletions(-) diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 7861a1c634c..56097d4f985 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -11,6 +11,7 @@ "dependencies": { "@azure/msal-common": "^16.6.2", "@azure/msal-node": "^5.2.2", + "@azure/msal-node-extensions": "^5.2.3", "@inquirer/confirm": "^6.1.0", "@inquirer/input": "^5.1.0", "@inquirer/select": "^5.2.0", @@ -393,6 +394,37 @@ "node": ">=20" } }, + "node_modules/@azure/msal-node-extensions": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@azure/msal-node-extensions/-/msal-node-extensions-5.2.3.tgz", + "integrity": "sha512-mPp4pvyuVm25cVrP/XXb2SmHMfiyj7Cby3gnC8DUwiviR9iykURHmqZlJLdcaISwai1dK2qxClc0SdzUIzUDMA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@azure/msal-common": "16.7.0", + "@azure/msal-node-runtime": "^0.20.0", + "keytar": "^7.8.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@azure/msal-node-extensions/node_modules/@azure/msal-common": { + "version": "16.7.0", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.7.0.tgz", + "integrity": "sha512-Jb8Y7pX6KM42SIT7KWP6YbY3+vLbwB5b5m+tpiiOzMU1QeyelQzs9lO8jv1e7/Uj9r7tg7VjPvW4T0KB1jF3UQ==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-node-runtime": { + "version": "0.20.5", + "resolved": "https://registry.npmjs.org/@azure/msal-node-runtime/-/msal-node-runtime-0.20.5.tgz", + "integrity": "sha512-DqY28Lpx67AsMbT3FYal3MnDZx62Pblnhp1qA+HGtgEsm3jK8COkJVCrhVsprn80PKQfAOdKRuxgVYyvmv2rOg==", + "hasInstallScript": true, + "license": "MIT" + }, "node_modules/@azure/opentelemetry-instrumentation-azure-sdk": { "version": "1.0.0", "license": "MIT", @@ -2730,6 +2762,26 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/big-integer": { "version": "1.6.52", "license": "Unlicense", @@ -2737,6 +2789,17 @@ "node": ">=0.6" } }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, "node_modules/boxen": { "version": "7.1.1", "dev": true, @@ -2802,6 +2865,30 @@ "version": "1.0.0", "license": "MIT" }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "license": "BSD-3-Clause" @@ -2910,6 +2997,12 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, "node_modules/cjs-module-lexer": { "version": "2.2.0", "license": "MIT" @@ -3188,6 +3281,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/deep-extend": { "version": "0.6.0", "license": "MIT", @@ -3252,6 +3360,15 @@ "node": ">=0.4.0" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/diagnostic-channel": { "version": "1.1.1", "license": "MIT", @@ -3344,6 +3461,15 @@ "dev": true, "license": "MIT" }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "license": "MIT", @@ -3678,6 +3804,15 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "dev": true, @@ -3886,6 +4021,12 @@ "dev": true, "license": "MIT" }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, "node_modules/function-bind": { "version": "1.1.2", "license": "MIT", @@ -3957,6 +4098,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, "node_modules/glob": { "version": "10.5.0", "dev": true, @@ -4124,6 +4271,26 @@ "node": ">=0.10.0" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "7.0.5", "dev": true, @@ -4498,6 +4665,17 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/keytar": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/keytar/-/keytar-7.9.0.tgz", + "integrity": "sha512-VPD8mtVtm5JNtA2AErl6Chp06JBfy7diFQ7TQQhdpWOl6MrCRB+eRbvAZUsbGQS9kiMq0coJsy0W0vHpDCkWsQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^4.3.0", + "prebuild-install": "^7.0.1" + } + }, "node_modules/keyv": { "version": "4.5.4", "dev": true, @@ -4724,6 +4902,18 @@ "node": ">=6" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { "version": "9.0.9", "dev": true, @@ -4753,6 +4943,12 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, "node_modules/mocha": { "version": "11.7.6", "resolved": "https://registry.npmjs.org/mocha/-/mocha-11.7.6.tgz", @@ -4838,11 +5034,35 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, "node_modules/natural-compare": { "version": "1.4.0", "dev": true, "license": "MIT" }, + "node_modules/node-abi": { + "version": "3.92.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", + "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-4.3.0.tgz", + "integrity": "sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==", + "license": "MIT" + }, "node_modules/node-cleanup": { "version": "2.1.2", "dev": true, @@ -4885,6 +5105,15 @@ "node": ">=0.8.0" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/onetime": { "version": "5.1.2", "license": "MIT", @@ -5132,6 +5361,33 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "dev": true, @@ -5204,6 +5460,16 @@ "node": ">= 0.10" } }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "dev": true, @@ -5575,6 +5841,51 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/sinon": { "version": "22.0.0", "resolved": "https://registry.npmjs.org/sinon/-/sinon-22.0.0.tgz", @@ -5848,6 +6159,34 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/test-exclude": { "version": "8.0.0", "dev": true, @@ -6005,6 +6344,18 @@ "node": ">=0.6.11 <=0.7.0 || >=0.7.3" } }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/type-check": { "version": "0.4.0", "dev": true, @@ -6397,6 +6748,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, "node_modules/wsl-utils": { "version": "0.3.1", "license": "MIT", diff --git a/package.json b/package.json index 63397e7c4f4..03f49a4a5a4 100644 --- a/package.json +++ b/package.json @@ -304,6 +304,7 @@ "dependencies": { "@azure/msal-common": "^16.6.2", "@azure/msal-node": "^5.2.2", + "@azure/msal-node-extensions": "^5.2.3", "@inquirer/confirm": "^6.1.0", "@inquirer/input": "^5.1.0", "@inquirer/select": "^5.2.0", diff --git a/src/Auth.spec.ts b/src/Auth.spec.ts index 2711e2dd6f9..d3a12d51626 100644 --- a/src/Auth.spec.ts +++ b/src/Auth.spec.ts @@ -8,6 +8,7 @@ import sinon from 'sinon'; import { Auth, AuthType, CertificateType, CloudType, Connection, InteractiveAuthorizationCodeResponse, InteractiveAuthorizationErrorResponse } from './Auth.js'; import authServer from './AuthServer.js'; import { FileTokenStorage } from './auth/FileTokenStorage.js'; +import { msalCachePlugin } from './auth/msalCachePlugin.js'; import { TokenStorage } from './auth/TokenStorage.js'; import { cli } from './cli/cli.js'; import { Logger } from './cli/Logger.js'; @@ -96,6 +97,8 @@ describe('Auth', () => { beforeEach(() => { log = []; auth = new Auth(); + sinon.stub(msalCachePlugin, 'getCachePlugin').resolves(mockTokenCachePlugin); + sinon.stub(msalCachePlugin, 'clearMsalCache').resolves(); response = { deviceCode: "", expiresIn: 0, @@ -142,7 +145,9 @@ describe('Auth', () => { publicApplication.acquireTokenByDeviceCode, publicApplication.acquireTokenByUsernamePassword, publicApplication.acquireTokenByCode, - tokenCache.getAllAccounts + tokenCache.getAllAccounts, + msalCachePlugin.getCachePlugin, + msalCachePlugin.clearMsalCache ]); openStub.restore(); clipboardStub.restore(); @@ -1696,12 +1701,6 @@ describe('Auth', () => { assert(actual instanceof FileTokenStorage); }); - it('configures MSAL cache storage as token storage', async () => { - const actual = (auth as any).getMsalCacheStorage(); - assert(actual instanceof FileTokenStorage); - assert.strictEqual((actual as any).filePath, FileTokenStorage.msalCacheFilePath()); - }); - it('restores authentication', async () => { await auth.restoreAuth(); assert.strictEqual(auth.connection.active, true); @@ -1766,18 +1765,15 @@ describe('Auth', () => { it('clears connection information in the configured token storage', async () => { const mockStorage1 = new MockTokenStorage(); const mockStorage2 = new MockTokenStorage(); - const mockStorage3 = new MockTokenStorage(); const mockStorageRemoveStub1 = sinon.stub(mockStorage1, 'remove').resolves(); const mockStorageRemoveStub2 = sinon.stub(mockStorage2, 'remove').resolves(); - const mockStorageRemoveStub3 = sinon.stub(mockStorage3, 'remove').resolves(); sinon.stub(auth, 'getConnectionStorage').returns(mockStorage1); sinon.stub(auth, 'getAllConnectionsStorage').returns(mockStorage2); - sinon.stub(auth as any, 'getMsalCacheStorage').callsFake(() => mockStorage3); await auth.clearConnectionInfo(); assert(mockStorageRemoveStub1.called, 'Active connection Storage not cleared'); assert(mockStorageRemoveStub2.called, 'All connections Storage not cleared'); - assert(mockStorageRemoveStub3.called, 'token storage or MSAL cache not cleared'); + assert((msalCachePlugin.clearMsalCache as sinon.SinonStub).called, 'MSAL cache not cleared'); }); it('removes a connection from the configured token storage', async () => { diff --git a/src/Auth.ts b/src/Auth.ts index f567b1cb0ae..77783634b27 100644 --- a/src/Auth.ts +++ b/src/Auth.ts @@ -343,7 +343,7 @@ export class Auth { return { auth: authConfig, cache: { - cachePlugin: msalCachePlugin + cachePlugin: await msalCachePlugin.getCachePlugin() }, system: { loggerOptions: { @@ -916,8 +916,7 @@ export class Auth { // we need to manually clear MSAL cache, because MSAL doesn't have support // for logging out when using cert-based auth - const msalCache = this.getMsalCacheStorage(); - await msalCache.remove(); + await msalCachePlugin.clearMsalCache(); } public async removeConnectionInfo(connection: Connection, logger: Logger, debug: boolean): Promise { @@ -960,9 +959,6 @@ export class Auth { return new FileTokenStorage(FileTokenStorage.connectionInfoFilePath()); } - private getMsalCacheStorage(): TokenStorage { - return new FileTokenStorage(FileTokenStorage.msalCacheFilePath()); - } public getAllConnectionsStorage(): TokenStorage { return new FileTokenStorage(FileTokenStorage.allConnectionsFilePath()); diff --git a/src/auth/FileTokenStorage.spec.ts b/src/auth/FileTokenStorage.spec.ts index c0e013dee21..3e7c3abf18c 100644 --- a/src/auth/FileTokenStorage.spec.ts +++ b/src/auth/FileTokenStorage.spec.ts @@ -1,7 +1,5 @@ import assert from 'assert'; import fs from 'fs'; -import os from 'os'; -import path from 'path'; import sinon from 'sinon'; import { AuthType, CertificateType, CloudType, Connection } from '../Auth.js'; import { sinonUtil } from '../utils/sinonUtil.js'; @@ -18,10 +16,6 @@ describe('FileTokenStorage', () => { ]); }); - it(`stores MSAL cache in the user's home directory`, () => { - assert.strictEqual(FileTokenStorage.msalCacheFilePath(), path.join(os.homedir(), '.cli-m365-msal.json')); - }); - it('fails retrieving connection info from file if the token file doesn\'t exist', async () => { sinon.stub(fs, 'existsSync').returns(false); try { diff --git a/src/auth/FileTokenStorage.ts b/src/auth/FileTokenStorage.ts index 2b05ec9f2ec..fe39e44d464 100644 --- a/src/auth/FileTokenStorage.ts +++ b/src/auth/FileTokenStorage.ts @@ -4,10 +4,6 @@ import path from 'path'; import { TokenStorage } from './TokenStorage.js'; export class FileTokenStorage implements TokenStorage { - public static msalCacheFilePath(): string { - return path.join(os.homedir(), '.cli-m365-msal.json'); - } - public static connectionInfoFilePath(): string { return path.join(os.homedir(), '.cli-m365-connection.json'); } diff --git a/src/auth/msalCachePlugin.spec.ts b/src/auth/msalCachePlugin.spec.ts index 1b563d51569..5666049cadd 100644 --- a/src/auth/msalCachePlugin.spec.ts +++ b/src/auth/msalCachePlugin.spec.ts @@ -1,70 +1,87 @@ -import { ISerializableTokenCache, TokenCacheContext } from '@azure/msal-node'; +import type { ICachePlugin } from '@azure/msal-node'; +import type { IPersistence } from '@azure/msal-node-extensions'; import assert from 'assert'; import sinon from 'sinon'; -import { sinonUtil } from '../utils/sinonUtil.js'; import { msalCachePlugin } from './msalCachePlugin.js'; -const mockCache: ISerializableTokenCache = { - deserialize: () => { }, - serialize: () => '' -}; -const mockCacheContext = new TokenCacheContext(mockCache, false); - describe('msalCachePlugin', () => { - let mockCacheDeserializeSpy: sinon.SinonSpy; - let mockCacheSerializeSpy: sinon.SinonSpy; + let mockPersistence: IPersistence; + let mockPlugin: ICachePlugin; + + beforeEach(() => { + msalCachePlugin.resetForTesting(); + + mockPersistence = { + save: sinon.stub().resolves(), + load: sinon.stub().resolves(null), + delete: sinon.stub().resolves(true), + reloadNecessary: sinon.stub().resolves(true), + getFilePath: sinon.stub().returns('/tmp/test-cache.json'), + getLogger: sinon.stub().returns({ + info: () => { }, + verbose: () => { }, + error: () => { }, + warning: () => { }, + trace: () => { } + }), + verifyPersistence: sinon.stub().resolves(true), + createForPersistenceValidation: sinon.stub().resolves() + } as unknown as IPersistence; - before(() => { - mockCacheDeserializeSpy = sinon.spy(mockCache, 'deserialize'); - mockCacheSerializeSpy = sinon.spy(mockCache, 'serialize'); + mockPlugin = { + beforeCacheAccess: sinon.stub().resolves(), + afterCacheAccess: sinon.stub().resolves() + }; }); afterEach(() => { - mockCacheDeserializeSpy.resetHistory(); - mockCacheSerializeSpy.resetHistory(); - mockCacheContext.hasChanged = false; - sinonUtil.restore([ - (msalCachePlugin as any).fileTokenStorage.get, - (msalCachePlugin as any).fileTokenStorage.set - ]); + sinon.restore(); + msalCachePlugin.resetForTesting(); }); - it(`restores token cache from the cache storage`, async () => { - sinon.stub((msalCachePlugin as any).fileTokenStorage, 'get').returns(''); - await msalCachePlugin.beforeCacheAccess(mockCacheContext); - assert(mockCacheDeserializeSpy.called); + it(`creates persistence using PersistenceCreator`, async () => { + const persistence = await msalCachePlugin.createPersistence(); + assert.notStrictEqual(persistence, undefined); }); - it(`doesn't fail restoring cache if cache file not found`, async () => { - sinon.stub((msalCachePlugin as any).fileTokenStorage, 'get').rejects('File not found'); - await msalCachePlugin.beforeCacheAccess(mockCacheContext); - assert(mockCacheDeserializeSpy.notCalled); + it(`creates plugin using PersistenceCachePlugin`, async () => { + const plugin = await msalCachePlugin.createPlugin(mockPersistence); + assert.notStrictEqual(plugin, undefined); + assert.notStrictEqual(plugin.beforeCacheAccess, undefined); + assert.notStrictEqual(plugin.afterCacheAccess, undefined); }); - it(`doesn't fail restoring cache if an error has occurred`, async () => { - sinon.stub((msalCachePlugin as any).fileTokenStorage, 'get').rejects('An error has occurred'); - await msalCachePlugin.beforeCacheAccess(mockCacheContext); - assert(mockCacheDeserializeSpy.notCalled); + it(`returns a cache plugin from msal-node-extensions`, async () => { + sinon.stub(msalCachePlugin, 'createPersistence').resolves(mockPersistence); + sinon.stub(msalCachePlugin, 'createPlugin').resolves(mockPlugin); + + const plugin = await msalCachePlugin.getCachePlugin(); + assert.strictEqual(plugin, mockPlugin); }); - it(`persists cache on disk when cache changed`, async () => { - sinon.stub((msalCachePlugin as any).fileTokenStorage, 'set').resolves(); - mockCacheContext.hasChanged = true; + it(`returns the same instance on subsequent calls`, async () => { + sinon.stub(msalCachePlugin, 'createPersistence').resolves(mockPersistence); + sinon.stub(msalCachePlugin, 'createPlugin').resolves(mockPlugin); - await msalCachePlugin.afterCacheAccess(mockCacheContext); - assert(mockCacheSerializeSpy.called); + const plugin1 = await msalCachePlugin.getCachePlugin(); + const plugin2 = await msalCachePlugin.getCachePlugin(); + assert.strictEqual(plugin1, plugin2); }); - it(`doesn't persist cache on disk when cache not changed`, async () => { - sinon.stub((msalCachePlugin as any).fileTokenStorage, 'set').resolves(); - await msalCachePlugin.afterCacheAccess(mockCacheContext); - assert(mockCacheSerializeSpy.notCalled); + it(`clears MSAL cache via persistence delete`, async () => { + sinon.stub(msalCachePlugin, 'createPersistence').resolves(mockPersistence); + sinon.stub(msalCachePlugin, 'createPlugin').resolves(mockPlugin); + + await msalCachePlugin.clearMsalCache(); + assert((mockPersistence.delete as sinon.SinonStub).calledOnce); }); - it(`doesn't throw exception when persisting cache failed`, async () => { - sinon.stub((msalCachePlugin as any).fileTokenStorage, 'set').rejects('An error has occurred'); - mockCacheContext.hasChanged = true; - await msalCachePlugin.afterCacheAccess(mockCacheContext); - assert(mockCacheSerializeSpy.called); + it(`initializes persistence only once when clearing cache after getting plugin`, async () => { + const createPersistenceStub = sinon.stub(msalCachePlugin, 'createPersistence').resolves(mockPersistence); + sinon.stub(msalCachePlugin, 'createPlugin').resolves(mockPlugin); + + await msalCachePlugin.getCachePlugin(); + await msalCachePlugin.clearMsalCache(); + assert.strictEqual(createPersistenceStub.callCount, 1); }); }); \ No newline at end of file diff --git a/src/auth/msalCachePlugin.ts b/src/auth/msalCachePlugin.ts index 48e99c04c96..3917e137a77 100644 --- a/src/auth/msalCachePlugin.ts +++ b/src/auth/msalCachePlugin.ts @@ -1,33 +1,52 @@ -import type { ICachePlugin, TokenCacheContext } from '@azure/msal-node'; -import { FileTokenStorage } from './FileTokenStorage.js'; -import { TokenStorage } from './TokenStorage.js'; +import type { ICachePlugin } from '@azure/msal-node'; +import type { IPersistence } from '@azure/msal-node-extensions'; +import os from 'os'; +import path from 'path'; -class MsalCachePlugin implements ICachePlugin { - private fileTokenStorage: TokenStorage = new FileTokenStorage(FileTokenStorage.msalCacheFilePath()); +const persistenceConfiguration = { + cachePath: path.join(os.homedir(), '.cli-m365-msal.json'), + serviceName: 'cli-microsoft365', + accountName: 'msal-cache', + usePlaintextFileOnLinux: true +}; - public async beforeCacheAccess(tokenCacheContext: TokenCacheContext): Promise { - try { - const data: string = await this.fileTokenStorage.get(); - tokenCacheContext.tokenCache.deserialize(data); - } - catch { - // Do nothing - } - } +let _initPromise: Promise<{ plugin: ICachePlugin; persistence: IPersistence }> | undefined; - public async afterCacheAccess(tokenCacheContext: TokenCacheContext): Promise { - if (!tokenCacheContext.cacheHasChanged) { - return; - } +export const msalCachePlugin = { + async createPersistence(): Promise { + const { DataProtectionScope, PersistenceCreator } = await import('@azure/msal-node-extensions'); + return PersistenceCreator.createPersistence({ + ...persistenceConfiguration, + dataProtectionScope: DataProtectionScope.CurrentUser + }); + }, - try { - await this.fileTokenStorage.set(tokenCacheContext.tokenCache.serialize()); - } - catch { - // Do nothing - } - } -} + async createPlugin(persistence: IPersistence): Promise { + const { PersistenceCachePlugin } = await import('@azure/msal-node-extensions'); + return new PersistenceCachePlugin(persistence); + }, + + async getCachePlugin(): Promise { + _initPromise ??= (async () => { + const persistence = await msalCachePlugin.createPersistence(); + const plugin = await msalCachePlugin.createPlugin(persistence); + return { plugin, persistence }; + })(); + const { plugin } = await _initPromise; + return plugin; + }, -const msalCachePlugin = new MsalCachePlugin(); -export { msalCachePlugin }; \ No newline at end of file + async clearMsalCache(): Promise { + _initPromise ??= (async () => { + const persistence = await msalCachePlugin.createPersistence(); + const plugin = await msalCachePlugin.createPlugin(persistence); + return { plugin, persistence }; + })(); + const { persistence } = await _initPromise; + await persistence.delete(); + }, + + resetForTesting(): void { + _initPromise = undefined; + } +}; \ No newline at end of file From c7cd6da59f1c14f2d0803b63d9f07b9d183e68b4 Mon Sep 17 00:00:00 2001 From: Waldek Mastykarz Date: Wed, 10 Jun 2026 09:26:45 +0200 Subject: [PATCH 2/8] Remove legacy plaintext MSAL cache file on first run On upgrade, the old ~/.cli-m365-msal.json may contain plaintext tokens (especially on macOS where the new persistence uses Keychain). Delete the file during initialization if it contains valid JSON, indicating it's a legacy plaintext cache rather than new persistence-managed content. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/auth/msalCachePlugin.spec.ts | 71 ++++++++++++++++++++++++++++++++ src/auth/msalCachePlugin.ts | 26 +++++++++++- 2 files changed, 96 insertions(+), 1 deletion(-) diff --git a/src/auth/msalCachePlugin.spec.ts b/src/auth/msalCachePlugin.spec.ts index 5666049cadd..7476c8c29bf 100644 --- a/src/auth/msalCachePlugin.spec.ts +++ b/src/auth/msalCachePlugin.spec.ts @@ -1,7 +1,9 @@ import type { ICachePlugin } from '@azure/msal-node'; import type { IPersistence } from '@azure/msal-node-extensions'; import assert from 'assert'; +import fs from 'fs'; import sinon from 'sinon'; +import { sinonUtil } from '../utils/sinonUtil.js'; import { msalCachePlugin } from './msalCachePlugin.js'; describe('msalCachePlugin', () => { @@ -35,6 +37,11 @@ describe('msalCachePlugin', () => { }); afterEach(() => { + sinonUtil.restore([ + fs.existsSync, + fs.readFileSync, + fs.unlinkSync + ]); sinon.restore(); msalCachePlugin.resetForTesting(); }); @@ -54,6 +61,7 @@ describe('msalCachePlugin', () => { it(`returns a cache plugin from msal-node-extensions`, async () => { sinon.stub(msalCachePlugin, 'createPersistence').resolves(mockPersistence); sinon.stub(msalCachePlugin, 'createPlugin').resolves(mockPlugin); + sinon.stub(msalCachePlugin, 'removeLegacyCache'); const plugin = await msalCachePlugin.getCachePlugin(); assert.strictEqual(plugin, mockPlugin); @@ -62,6 +70,7 @@ describe('msalCachePlugin', () => { it(`returns the same instance on subsequent calls`, async () => { sinon.stub(msalCachePlugin, 'createPersistence').resolves(mockPersistence); sinon.stub(msalCachePlugin, 'createPlugin').resolves(mockPlugin); + sinon.stub(msalCachePlugin, 'removeLegacyCache'); const plugin1 = await msalCachePlugin.getCachePlugin(); const plugin2 = await msalCachePlugin.getCachePlugin(); @@ -71,6 +80,7 @@ describe('msalCachePlugin', () => { it(`clears MSAL cache via persistence delete`, async () => { sinon.stub(msalCachePlugin, 'createPersistence').resolves(mockPersistence); sinon.stub(msalCachePlugin, 'createPlugin').resolves(mockPlugin); + sinon.stub(msalCachePlugin, 'removeLegacyCache'); await msalCachePlugin.clearMsalCache(); assert((mockPersistence.delete as sinon.SinonStub).calledOnce); @@ -79,9 +89,70 @@ describe('msalCachePlugin', () => { it(`initializes persistence only once when clearing cache after getting plugin`, async () => { const createPersistenceStub = sinon.stub(msalCachePlugin, 'createPersistence').resolves(mockPersistence); sinon.stub(msalCachePlugin, 'createPlugin').resolves(mockPlugin); + sinon.stub(msalCachePlugin, 'removeLegacyCache'); await msalCachePlugin.getCachePlugin(); await msalCachePlugin.clearMsalCache(); assert.strictEqual(createPersistenceStub.callCount, 1); }); + + it(`removes legacy plaintext cache file when it exists`, () => { + sinon.stub(fs, 'existsSync').returns(true); + sinon.stub(fs, 'readFileSync').returns('{"AccessToken":{}}'); + const unlinkStub = sinon.stub(fs, 'unlinkSync'); + + msalCachePlugin.removeLegacyCache(); + assert(unlinkStub.calledOnce); + }); + + it(`does not remove cache file when it does not exist`, () => { + sinon.stub(fs, 'existsSync').returns(false); + const unlinkStub = sinon.stub(fs, 'unlinkSync'); + + msalCachePlugin.removeLegacyCache(); + assert(unlinkStub.notCalled); + }); + + it(`does not remove cache file when it is empty`, () => { + sinon.stub(fs, 'existsSync').returns(true); + sinon.stub(fs, 'readFileSync').returns(''); + const unlinkStub = sinon.stub(fs, 'unlinkSync'); + + msalCachePlugin.removeLegacyCache(); + assert(unlinkStub.notCalled); + }); + + it(`does not fail when cache file contains non-JSON content`, () => { + sinon.stub(fs, 'existsSync').returns(true); + sinon.stub(fs, 'readFileSync').returns('not-json-content'); + const unlinkStub = sinon.stub(fs, 'unlinkSync'); + + msalCachePlugin.removeLegacyCache(); + assert(unlinkStub.notCalled); + }); + + it(`does not fail when reading cache file throws error`, () => { + sinon.stub(fs, 'existsSync').throws(new Error('An error has occurred')); + + msalCachePlugin.removeLegacyCache(); + // no assertion needed - just verifying it doesn't throw + }); + + it(`calls removeLegacyCache during initialization via getCachePlugin`, async () => { + sinon.stub(msalCachePlugin, 'createPersistence').resolves(mockPersistence); + sinon.stub(msalCachePlugin, 'createPlugin').resolves(mockPlugin); + const removeLegacyStub = sinon.stub(msalCachePlugin, 'removeLegacyCache'); + + await msalCachePlugin.getCachePlugin(); + assert(removeLegacyStub.calledOnce); + }); + + it(`calls removeLegacyCache during initialization via clearMsalCache`, async () => { + sinon.stub(msalCachePlugin, 'createPersistence').resolves(mockPersistence); + sinon.stub(msalCachePlugin, 'createPlugin').resolves(mockPlugin); + const removeLegacyStub = sinon.stub(msalCachePlugin, 'removeLegacyCache'); + + await msalCachePlugin.clearMsalCache(); + assert(removeLegacyStub.calledOnce); + }); }); \ No newline at end of file diff --git a/src/auth/msalCachePlugin.ts b/src/auth/msalCachePlugin.ts index 3917e137a77..ac1406ca5f1 100644 --- a/src/auth/msalCachePlugin.ts +++ b/src/auth/msalCachePlugin.ts @@ -1,10 +1,13 @@ import type { ICachePlugin } from '@azure/msal-node'; import type { IPersistence } from '@azure/msal-node-extensions'; +import fs from 'fs'; import os from 'os'; import path from 'path'; +const legacyCachePath = path.join(os.homedir(), '.cli-m365-msal.json'); + const persistenceConfiguration = { - cachePath: path.join(os.homedir(), '.cli-m365-msal.json'), + cachePath: legacyCachePath, serviceName: 'cli-microsoft365', accountName: 'msal-cache', usePlaintextFileOnLinux: true @@ -26,8 +29,28 @@ export const msalCachePlugin = { return new PersistenceCachePlugin(persistence); }, + removeLegacyCache(): void { + try { + if (fs.existsSync(legacyCachePath)) { + const contents = fs.readFileSync(legacyCachePath, 'utf8'); + // Legacy cache is a plain JSON object with token data. + // If parsing succeeds and the file has content, it's a + // legacy plaintext cache that should be removed. + if (contents.trim().length > 0) { + JSON.parse(contents); + fs.unlinkSync(legacyCachePath); + } + } + } + catch { + // Ignore errors: file may already be managed by the new + // persistence layer (e.g. DPAPI-encrypted on Windows) + } + }, + async getCachePlugin(): Promise { _initPromise ??= (async () => { + msalCachePlugin.removeLegacyCache(); const persistence = await msalCachePlugin.createPersistence(); const plugin = await msalCachePlugin.createPlugin(persistence); return { plugin, persistence }; @@ -38,6 +61,7 @@ export const msalCachePlugin = { async clearMsalCache(): Promise { _initPromise ??= (async () => { + msalCachePlugin.removeLegacyCache(); const persistence = await msalCachePlugin.createPersistence(); const plugin = await msalCachePlugin.createPlugin(persistence); return { plugin, persistence }; From ee567ba3ef62f06fea4c9c446c448a899f66bc88 Mon Sep 17 00:00:00 2001 From: Waldek Mastykarz Date: Wed, 10 Jun 2026 09:33:14 +0200 Subject: [PATCH 3/8] Update docs to reflect OS credential store for MSAL cache Update persisting-connection.mdx to document that the MSAL token cache is now stored in the OS-specific credential store (macOS Keychain, Windows DPAPI, Linux libsecret) instead of a plaintext JSON file. Also fix the connection file names to match current code (.cli-m365-connection and .cli-m365-all-connections). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/docs/concepts/persisting-connection.mdx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/docs/concepts/persisting-connection.mdx b/docs/docs/concepts/persisting-connection.mdx index b3ad8004d3b..c2ba334626b 100644 --- a/docs/docs/concepts/persisting-connection.mdx +++ b/docs/docs/concepts/persisting-connection.mdx @@ -43,7 +43,9 @@ When you log in to Microsoft 365 in the CLI for Microsoft 365, the CLI will pers Depending on the CLI for Microsoft 365 commands you have used, the CLI might persist some additional information. For example, when using commands that interact with SharePoint Online, the CLI will store the URL of your SharePoint Online tenant as well as its ID. -The CLI for Microsoft 365 stores its connection information in two JSON files located in the home directory of the current user. On MacOS and Linux, these are `~/.cli-m365-tokens.json` and `~/.cli-m365-msal.json`. On Windows, this is `\Users\\.cli-m365-tokens.json` and `\Users\\.cli-m365-msal.json`. The `tokens` file serves as a quick lookup of tokens to speed up command execution. The `msal` file is the cache of the `msal-node` library used by CLI for Microsoft 365 to authenticate to Microsoft 365. Contents of both these files are not encrypted. +The CLI for Microsoft 365 stores its connection information in JSON files located in the home directory of the current user. On macOS and Linux, the active connection is stored in `~/.cli-m365-connection.json` and all connections in `~/.cli-m365-all-connections.json`. On Windows, these are `\Users\\.cli-m365-connection.json` and `\Users\\.cli-m365-all-connections.json`. These files serve as a quick lookup of connection details to speed up command execution. + +The MSAL token cache, which contains access and refresh tokens, is stored securely using the OS-specific credential store: macOS Keychain, Windows DPAPI, or Linux libsecret. If libsecret is unavailable on Linux, the CLI falls back to a plaintext file. ## Removing persisted connection information From 032f97801e24123c0395e18a0aebae3e8f3cf088 Mon Sep 17 00:00:00 2001 From: Waldek Mastykarz Date: Wed, 10 Jun 2026 09:41:00 +0200 Subject: [PATCH 4/8] Fall back to FilePersistence when libsecret is unavailable On Linux without libsecret installed, PersistenceCreator.createPersistence() fails because the keytar native module cannot load libsecret-1.so.0. The usePlaintextFileOnLinux fallback in PersistenceCreator only handles verification failures, not module load failures. Catch the error in createPersistence() and fall back to FilePersistence directly, ensuring the CLI works on Linux environments without libsecret (CI, containers, headless servers). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/auth/msalCachePlugin.spec.ts | 10 ++++++++++ src/auth/msalCachePlugin.ts | 18 +++++++++++++----- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/src/auth/msalCachePlugin.spec.ts b/src/auth/msalCachePlugin.spec.ts index 7476c8c29bf..7aebf89db23 100644 --- a/src/auth/msalCachePlugin.spec.ts +++ b/src/auth/msalCachePlugin.spec.ts @@ -51,6 +51,16 @@ describe('msalCachePlugin', () => { assert.notStrictEqual(persistence, undefined); }); + it(`falls back to FilePersistence when PersistenceCreator fails`, async () => { + const msalExtensions = await import('@azure/msal-node-extensions'); + sinon.stub(msalExtensions.PersistenceCreator, 'createPersistence').rejects(new Error('libsecret not available')); + const filePersistenceCreateStub = sinon.stub(msalExtensions.FilePersistence, 'create').resolves(mockPersistence as any); + + const persistence = await msalCachePlugin.createPersistence(); + assert(filePersistenceCreateStub.calledOnce); + assert.strictEqual(persistence, mockPersistence); + }); + it(`creates plugin using PersistenceCachePlugin`, async () => { const plugin = await msalCachePlugin.createPlugin(mockPersistence); assert.notStrictEqual(plugin, undefined); diff --git a/src/auth/msalCachePlugin.ts b/src/auth/msalCachePlugin.ts index ac1406ca5f1..eaf92217d93 100644 --- a/src/auth/msalCachePlugin.ts +++ b/src/auth/msalCachePlugin.ts @@ -17,11 +17,19 @@ let _initPromise: Promise<{ plugin: ICachePlugin; persistence: IPersistence }> | export const msalCachePlugin = { async createPersistence(): Promise { - const { DataProtectionScope, PersistenceCreator } = await import('@azure/msal-node-extensions'); - return PersistenceCreator.createPersistence({ - ...persistenceConfiguration, - dataProtectionScope: DataProtectionScope.CurrentUser - }); + const { DataProtectionScope, FilePersistence, PersistenceCreator } = await import('@azure/msal-node-extensions'); + try { + return await PersistenceCreator.createPersistence({ + ...persistenceConfiguration, + dataProtectionScope: DataProtectionScope.CurrentUser + }); + } + catch { + // PersistenceCreator fails on Linux when libsecret is not installed + // because the keytar native module cannot load. Fall back to an + // unencrypted file, which matches the usePlaintextFileOnLinux intent. + return FilePersistence.create(persistenceConfiguration.cachePath); + } }, async createPlugin(persistence: IPersistence): Promise { From ad03aeaecbbbb82ff72707fa7ec30be7d1d199ae Mon Sep 17 00:00:00 2001 From: Waldek Mastykarz Date: Wed, 10 Jun 2026 09:55:34 +0200 Subject: [PATCH 5/8] Handle barrel import failure on Linux without libsecret Replace createPersistence/createPlugin seams with a single createNativePersistence method that wraps the entire dynamic import of @azure/msal-node-extensions in a try/catch. When the barrel export fails to load (e.g. Linux without libsecret installed), fall back to a built-in FileCachePlugin that reads/writes tokens to a plain JSON file, matching the original custom cache plugin behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/auth/msalCachePlugin.spec.ts | 195 ++++++++++++++++++++++--------- src/auth/msalCachePlugin.ts | 98 +++++++++++----- 2 files changed, 208 insertions(+), 85 deletions(-) diff --git a/src/auth/msalCachePlugin.spec.ts b/src/auth/msalCachePlugin.spec.ts index 7aebf89db23..a0bf442981a 100644 --- a/src/auth/msalCachePlugin.spec.ts +++ b/src/auth/msalCachePlugin.spec.ts @@ -1,5 +1,4 @@ import type { ICachePlugin } from '@azure/msal-node'; -import type { IPersistence } from '@azure/msal-node-extensions'; import assert from 'assert'; import fs from 'fs'; import sinon from 'sinon'; @@ -7,29 +6,11 @@ import { sinonUtil } from '../utils/sinonUtil.js'; import { msalCachePlugin } from './msalCachePlugin.js'; describe('msalCachePlugin', () => { - let mockPersistence: IPersistence; let mockPlugin: ICachePlugin; beforeEach(() => { msalCachePlugin.resetForTesting(); - mockPersistence = { - save: sinon.stub().resolves(), - load: sinon.stub().resolves(null), - delete: sinon.stub().resolves(true), - reloadNecessary: sinon.stub().resolves(true), - getFilePath: sinon.stub().returns('/tmp/test-cache.json'), - getLogger: sinon.stub().returns({ - info: () => { }, - verbose: () => { }, - error: () => { }, - warning: () => { }, - trace: () => { } - }), - verifyPersistence: sinon.stub().resolves(true), - createForPersistenceValidation: sinon.stub().resolves() - } as unknown as IPersistence; - mockPlugin = { beforeCacheAccess: sinon.stub().resolves(), afterCacheAccess: sinon.stub().resolves() @@ -40,70 +21,85 @@ describe('msalCachePlugin', () => { sinonUtil.restore([ fs.existsSync, fs.readFileSync, - fs.unlinkSync + fs.unlinkSync, + fs.writeFileSync ]); sinon.restore(); msalCachePlugin.resetForTesting(); }); - it(`creates persistence using PersistenceCreator`, async () => { - const persistence = await msalCachePlugin.createPersistence(); - assert.notStrictEqual(persistence, undefined); + it(`returns a cache plugin using native persistence`, async () => { + sinon.stub(msalCachePlugin, 'createNativePersistence').resolves({ + plugin: mockPlugin, + clearCache: sinon.stub().resolves() + }); + sinon.stub(msalCachePlugin, 'removeLegacyCache'); + + const plugin = await msalCachePlugin.getCachePlugin(); + assert.strictEqual(plugin, mockPlugin); }); - it(`falls back to FilePersistence when PersistenceCreator fails`, async () => { - const msalExtensions = await import('@azure/msal-node-extensions'); - sinon.stub(msalExtensions.PersistenceCreator, 'createPersistence').rejects(new Error('libsecret not available')); - const filePersistenceCreateStub = sinon.stub(msalExtensions.FilePersistence, 'create').resolves(mockPersistence as any); + it(`returns the same instance on subsequent calls`, async () => { + sinon.stub(msalCachePlugin, 'createNativePersistence').resolves({ + plugin: mockPlugin, + clearCache: sinon.stub().resolves() + }); + sinon.stub(msalCachePlugin, 'removeLegacyCache'); - const persistence = await msalCachePlugin.createPersistence(); - assert(filePersistenceCreateStub.calledOnce); - assert.strictEqual(persistence, mockPersistence); + const plugin1 = await msalCachePlugin.getCachePlugin(); + const plugin2 = await msalCachePlugin.getCachePlugin(); + assert.strictEqual(plugin1, plugin2); }); - it(`creates plugin using PersistenceCachePlugin`, async () => { - const plugin = await msalCachePlugin.createPlugin(mockPersistence); + it(`falls back to file-based cache when native persistence fails`, async () => { + sinon.stub(msalCachePlugin, 'createNativePersistence').rejects(new Error('libsecret not available')); + sinon.stub(msalCachePlugin, 'removeLegacyCache'); + + const plugin = await msalCachePlugin.getCachePlugin(); assert.notStrictEqual(plugin, undefined); assert.notStrictEqual(plugin.beforeCacheAccess, undefined); assert.notStrictEqual(plugin.afterCacheAccess, undefined); }); - it(`returns a cache plugin from msal-node-extensions`, async () => { - sinon.stub(msalCachePlugin, 'createPersistence').resolves(mockPersistence); - sinon.stub(msalCachePlugin, 'createPlugin').resolves(mockPlugin); + it(`clears MSAL cache via native persistence`, async () => { + const clearCacheStub = sinon.stub().resolves(); + sinon.stub(msalCachePlugin, 'createNativePersistence').resolves({ + plugin: mockPlugin, + clearCache: clearCacheStub + }); sinon.stub(msalCachePlugin, 'removeLegacyCache'); - const plugin = await msalCachePlugin.getCachePlugin(); - assert.strictEqual(plugin, mockPlugin); + await msalCachePlugin.clearMsalCache(); + assert(clearCacheStub.calledOnce); }); - it(`returns the same instance on subsequent calls`, async () => { - sinon.stub(msalCachePlugin, 'createPersistence').resolves(mockPersistence); - sinon.stub(msalCachePlugin, 'createPlugin').resolves(mockPlugin); + it(`clears file-based cache when native persistence fails`, async () => { + sinon.stub(msalCachePlugin, 'createNativePersistence').rejects(new Error('libsecret not available')); sinon.stub(msalCachePlugin, 'removeLegacyCache'); + const unlinkStub = sinon.stub(fs, 'unlinkSync'); - const plugin1 = await msalCachePlugin.getCachePlugin(); - const plugin2 = await msalCachePlugin.getCachePlugin(); - assert.strictEqual(plugin1, plugin2); + await msalCachePlugin.clearMsalCache(); + assert(unlinkStub.called); }); - it(`clears MSAL cache via persistence delete`, async () => { - sinon.stub(msalCachePlugin, 'createPersistence').resolves(mockPersistence); - sinon.stub(msalCachePlugin, 'createPlugin').resolves(mockPlugin); + it(`does not fail clearing file-based cache when file does not exist`, async () => { + sinon.stub(msalCachePlugin, 'createNativePersistence').rejects(new Error('libsecret not available')); sinon.stub(msalCachePlugin, 'removeLegacyCache'); + sinon.stub(fs, 'unlinkSync').throws(new Error('ENOENT')); await msalCachePlugin.clearMsalCache(); - assert((mockPersistence.delete as sinon.SinonStub).calledOnce); }); - it(`initializes persistence only once when clearing cache after getting plugin`, async () => { - const createPersistenceStub = sinon.stub(msalCachePlugin, 'createPersistence').resolves(mockPersistence); - sinon.stub(msalCachePlugin, 'createPlugin').resolves(mockPlugin); + it(`initializes only once when clearing cache after getting plugin`, async () => { + const createStub = sinon.stub(msalCachePlugin, 'createNativePersistence').resolves({ + plugin: mockPlugin, + clearCache: sinon.stub().resolves() + }); sinon.stub(msalCachePlugin, 'removeLegacyCache'); await msalCachePlugin.getCachePlugin(); await msalCachePlugin.clearMsalCache(); - assert.strictEqual(createPersistenceStub.callCount, 1); + assert.strictEqual(createStub.callCount, 1); }); it(`removes legacy plaintext cache file when it exists`, () => { @@ -145,12 +141,13 @@ describe('msalCachePlugin', () => { sinon.stub(fs, 'existsSync').throws(new Error('An error has occurred')); msalCachePlugin.removeLegacyCache(); - // no assertion needed - just verifying it doesn't throw }); it(`calls removeLegacyCache during initialization via getCachePlugin`, async () => { - sinon.stub(msalCachePlugin, 'createPersistence').resolves(mockPersistence); - sinon.stub(msalCachePlugin, 'createPlugin').resolves(mockPlugin); + sinon.stub(msalCachePlugin, 'createNativePersistence').resolves({ + plugin: mockPlugin, + clearCache: sinon.stub().resolves() + }); const removeLegacyStub = sinon.stub(msalCachePlugin, 'removeLegacyCache'); await msalCachePlugin.getCachePlugin(); @@ -158,11 +155,97 @@ describe('msalCachePlugin', () => { }); it(`calls removeLegacyCache during initialization via clearMsalCache`, async () => { - sinon.stub(msalCachePlugin, 'createPersistence').resolves(mockPersistence); - sinon.stub(msalCachePlugin, 'createPlugin').resolves(mockPlugin); + sinon.stub(msalCachePlugin, 'createNativePersistence').resolves({ + plugin: mockPlugin, + clearCache: sinon.stub().resolves() + }); const removeLegacyStub = sinon.stub(msalCachePlugin, 'removeLegacyCache'); await msalCachePlugin.clearMsalCache(); assert(removeLegacyStub.calledOnce); }); + + it(`file cache plugin deserializes token cache from file`, async () => { + sinon.stub(msalCachePlugin, 'createNativePersistence').rejects(new Error('not available')); + sinon.stub(msalCachePlugin, 'removeLegacyCache'); + + const plugin = await msalCachePlugin.getCachePlugin(); + + sinon.stub(fs, 'existsSync').returns(true); + sinon.stub(fs, 'readFileSync').returns('{"token":"data"}'); + const mockCache = { deserialize: sinon.stub(), serialize: sinon.stub().returns('') }; + const context = { tokenCache: mockCache, cacheHasChanged: false, hasChanged: false } as any; + + await plugin.beforeCacheAccess(context); + assert(mockCache.deserialize.calledWith('{"token":"data"}')); + }); + + it(`file cache plugin does not fail when cache file is missing`, async () => { + sinon.stub(msalCachePlugin, 'createNativePersistence').rejects(new Error('not available')); + sinon.stub(msalCachePlugin, 'removeLegacyCache'); + + const plugin = await msalCachePlugin.getCachePlugin(); + + sinon.stub(fs, 'existsSync').returns(false); + const mockCache = { deserialize: sinon.stub(), serialize: sinon.stub().returns('') }; + const context = { tokenCache: mockCache, cacheHasChanged: false, hasChanged: false } as any; + + await plugin.beforeCacheAccess(context); + assert(mockCache.deserialize.notCalled); + }); + + it(`file cache plugin serializes token cache to file when changed`, async () => { + sinon.stub(msalCachePlugin, 'createNativePersistence').rejects(new Error('not available')); + sinon.stub(msalCachePlugin, 'removeLegacyCache'); + + const plugin = await msalCachePlugin.getCachePlugin(); + + const writeStub = sinon.stub(fs, 'writeFileSync'); + const mockCache = { deserialize: sinon.stub(), serialize: sinon.stub().returns('{"serialized":"data"}') }; + const context = { tokenCache: mockCache, cacheHasChanged: true, hasChanged: true } as any; + + await plugin.afterCacheAccess(context); + assert(writeStub.calledOnce); + }); + + it(`file cache plugin does not write when cache not changed`, async () => { + sinon.stub(msalCachePlugin, 'createNativePersistence').rejects(new Error('not available')); + sinon.stub(msalCachePlugin, 'removeLegacyCache'); + + const plugin = await msalCachePlugin.getCachePlugin(); + + const writeStub = sinon.stub(fs, 'writeFileSync'); + const mockCache = { deserialize: sinon.stub(), serialize: sinon.stub().returns('') }; + const context = { tokenCache: mockCache, cacheHasChanged: false, hasChanged: false } as any; + + await plugin.afterCacheAccess(context); + assert(writeStub.notCalled); + }); + + it(`file cache plugin does not throw when writing fails`, async () => { + sinon.stub(msalCachePlugin, 'createNativePersistence').rejects(new Error('not available')); + sinon.stub(msalCachePlugin, 'removeLegacyCache'); + + const plugin = await msalCachePlugin.getCachePlugin(); + + sinon.stub(fs, 'writeFileSync').throws(new Error('write failed')); + const mockCache = { deserialize: sinon.stub(), serialize: sinon.stub().returns('data') }; + const context = { tokenCache: mockCache, cacheHasChanged: true, hasChanged: true } as any; + + await plugin.afterCacheAccess(context); + }); + + it(`file cache plugin does not throw when reading fails`, async () => { + sinon.stub(msalCachePlugin, 'createNativePersistence').rejects(new Error('not available')); + sinon.stub(msalCachePlugin, 'removeLegacyCache'); + + const plugin = await msalCachePlugin.getCachePlugin(); + + sinon.stub(fs, 'existsSync').throws(new Error('read failed')); + const mockCache = { deserialize: sinon.stub(), serialize: sinon.stub().returns('') }; + const context = { tokenCache: mockCache, cacheHasChanged: false, hasChanged: false } as any; + + await plugin.beforeCacheAccess(context); + assert(mockCache.deserialize.notCalled); + }); }); \ No newline at end of file diff --git a/src/auth/msalCachePlugin.ts b/src/auth/msalCachePlugin.ts index eaf92217d93..0e8ffc7acd0 100644 --- a/src/auth/msalCachePlugin.ts +++ b/src/auth/msalCachePlugin.ts @@ -1,5 +1,4 @@ -import type { ICachePlugin } from '@azure/msal-node'; -import type { IPersistence } from '@azure/msal-node-extensions'; +import type { ICachePlugin, TokenCacheContext } from '@azure/msal-node'; import fs from 'fs'; import os from 'os'; import path from 'path'; @@ -13,37 +12,58 @@ const persistenceConfiguration = { usePlaintextFileOnLinux: true }; -let _initPromise: Promise<{ plugin: ICachePlugin; persistence: IPersistence }> | undefined; +let _initPromise: Promise<{ plugin: ICachePlugin; clearCache: () => Promise }> | undefined; -export const msalCachePlugin = { - async createPersistence(): Promise { - const { DataProtectionScope, FilePersistence, PersistenceCreator } = await import('@azure/msal-node-extensions'); +class FileCachePlugin implements ICachePlugin { + private cachePath: string; + + constructor(cachePath: string) { + this.cachePath = cachePath; + } + + public async beforeCacheAccess(tokenCacheContext: TokenCacheContext): Promise { try { - return await PersistenceCreator.createPersistence({ - ...persistenceConfiguration, - dataProtectionScope: DataProtectionScope.CurrentUser - }); + if (fs.existsSync(this.cachePath)) { + const data = fs.readFileSync(this.cachePath, 'utf8'); + tokenCacheContext.tokenCache.deserialize(data); + } } catch { - // PersistenceCreator fails on Linux when libsecret is not installed - // because the keytar native module cannot load. Fall back to an - // unencrypted file, which matches the usePlaintextFileOnLinux intent. - return FilePersistence.create(persistenceConfiguration.cachePath); + // Do nothing } - }, + } + + public async afterCacheAccess(tokenCacheContext: TokenCacheContext): Promise { + if (!tokenCacheContext.cacheHasChanged) { + return; + } + + try { + fs.writeFileSync(this.cachePath, tokenCacheContext.tokenCache.serialize(), 'utf8'); + } + catch { + // Do nothing + } + } +} - async createPlugin(persistence: IPersistence): Promise { - const { PersistenceCachePlugin } = await import('@azure/msal-node-extensions'); - return new PersistenceCachePlugin(persistence); +export const msalCachePlugin = { + async createNativePersistence(): Promise<{ plugin: ICachePlugin; clearCache: () => Promise }> { + const { DataProtectionScope, PersistenceCachePlugin, PersistenceCreator } = await import('@azure/msal-node-extensions'); + const persistence = await PersistenceCreator.createPersistence({ + ...persistenceConfiguration, + dataProtectionScope: DataProtectionScope.CurrentUser + }); + return { + plugin: new PersistenceCachePlugin(persistence), + clearCache: async () => { await persistence.delete(); } + }; }, removeLegacyCache(): void { try { if (fs.existsSync(legacyCachePath)) { const contents = fs.readFileSync(legacyCachePath, 'utf8'); - // Legacy cache is a plain JSON object with token data. - // If parsing succeeds and the file has content, it's a - // legacy plaintext cache that should be removed. if (contents.trim().length > 0) { JSON.parse(contents); fs.unlinkSync(legacyCachePath); @@ -59,9 +79,20 @@ export const msalCachePlugin = { async getCachePlugin(): Promise { _initPromise ??= (async () => { msalCachePlugin.removeLegacyCache(); - const persistence = await msalCachePlugin.createPersistence(); - const plugin = await msalCachePlugin.createPlugin(persistence); - return { plugin, persistence }; + try { + return await msalCachePlugin.createNativePersistence(); + } + catch { + // Fall back to file-based cache when native persistence is + // unavailable (e.g. Linux without libsecret) + return { + plugin: new FileCachePlugin(persistenceConfiguration.cachePath), + clearCache: async () => { + try { fs.unlinkSync(persistenceConfiguration.cachePath); } + catch { /* file may not exist */ } + } + }; + } })(); const { plugin } = await _initPromise; return plugin; @@ -70,12 +101,21 @@ export const msalCachePlugin = { async clearMsalCache(): Promise { _initPromise ??= (async () => { msalCachePlugin.removeLegacyCache(); - const persistence = await msalCachePlugin.createPersistence(); - const plugin = await msalCachePlugin.createPlugin(persistence); - return { plugin, persistence }; + try { + return await msalCachePlugin.createNativePersistence(); + } + catch { + return { + plugin: new FileCachePlugin(persistenceConfiguration.cachePath), + clearCache: async () => { + try { fs.unlinkSync(persistenceConfiguration.cachePath); } + catch { /* file may not exist */ } + } + }; + } })(); - const { persistence } = await _initPromise; - await persistence.delete(); + const { clearCache } = await _initPromise; + await clearCache(); }, resetForTesting(): void { From 8a7fbf2a3564b9364de5f23f567f1549f89ce734 Mon Sep 17 00:00:00 2001 From: Waldek Mastykarz Date: Wed, 10 Jun 2026 10:41:42 +0200 Subject: [PATCH 6/8] Fix coverage for msalCachePlugin on Linux CI Extract importMsalExtensions and createFileFallback as separate stubbable seams so createNativePersistence body can be tested via mocks. Add test that calls real importMsalExtensions (covers line 52 even when the import throws on Linux without libsecret). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/auth/msalCachePlugin.spec.ts | 72 ++++++++++++++++++++++++++++++++ src/auth/msalCachePlugin.ts | 32 +++++++------- 2 files changed, 89 insertions(+), 15 deletions(-) diff --git a/src/auth/msalCachePlugin.spec.ts b/src/auth/msalCachePlugin.spec.ts index a0bf442981a..b40a7398eb9 100644 --- a/src/auth/msalCachePlugin.spec.ts +++ b/src/auth/msalCachePlugin.spec.ts @@ -28,6 +28,16 @@ describe('msalCachePlugin', () => { msalCachePlugin.resetForTesting(); }); + it(`importMsalExtensions loads the msal-node-extensions module`, async () => { + try { + const result = await msalCachePlugin.importMsalExtensions(); + assert.notStrictEqual(result, undefined); + } + catch { + // May fail on systems without native dependencies (e.g. libsecret on Linux) + } + }); + it(`returns a cache plugin using native persistence`, async () => { sinon.stub(msalCachePlugin, 'createNativePersistence').resolves({ plugin: mockPlugin, @@ -102,6 +112,68 @@ describe('msalCachePlugin', () => { assert.strictEqual(createStub.callCount, 1); }); + it(`creates native persistence using imported msal-extensions`, async () => { + const mockPersistence = { + delete: sinon.stub().resolves(true) + }; + sinon.stub(msalCachePlugin, 'importMsalExtensions').resolves({ + DataProtectionScope: { CurrentUser: 0 }, + PersistenceCreator: { + createPersistence: sinon.stub().resolves(mockPersistence) + }, + PersistenceCachePlugin: class { + constructor() { /* empty */ } + beforeCacheAccess(): Promise { return Promise.resolve(); } + afterCacheAccess(): Promise { return Promise.resolve(); } + } + } as any); + + const result = await msalCachePlugin.createNativePersistence(); + assert.notStrictEqual(result.plugin, undefined); + assert.notStrictEqual(result.clearCache, undefined); + }); + + it(`native persistence clearCache delegates to persistence.delete`, async () => { + const deleteStub = sinon.stub().resolves(true); + const mockPersistence = { delete: deleteStub }; + sinon.stub(msalCachePlugin, 'importMsalExtensions').resolves({ + DataProtectionScope: { CurrentUser: 0 }, + PersistenceCreator: { + createPersistence: sinon.stub().resolves(mockPersistence) + }, + PersistenceCachePlugin: class { + constructor() { /* empty */ } + beforeCacheAccess(): Promise { return Promise.resolve(); } + afterCacheAccess(): Promise { return Promise.resolve(); } + } + } as any); + + const result = await msalCachePlugin.createNativePersistence(); + await result.clearCache(); + assert(deleteStub.calledOnce); + }); + + it(`createFileFallback returns a plugin and clearCache function`, () => { + const result = msalCachePlugin.createFileFallback(); + assert.notStrictEqual(result.plugin, undefined); + assert.notStrictEqual(result.plugin.beforeCacheAccess, undefined); + assert.notStrictEqual(result.plugin.afterCacheAccess, undefined); + assert.notStrictEqual(result.clearCache, undefined); + }); + + it(`createFileFallback clearCache deletes the cache file`, async () => { + const unlinkStub = sinon.stub(fs, 'unlinkSync'); + const result = msalCachePlugin.createFileFallback(); + await result.clearCache(); + assert(unlinkStub.calledOnce); + }); + + it(`createFileFallback clearCache does not throw when file does not exist`, async () => { + sinon.stub(fs, 'unlinkSync').throws(new Error('ENOENT')); + const result = msalCachePlugin.createFileFallback(); + await result.clearCache(); + }); + it(`removes legacy plaintext cache file when it exists`, () => { sinon.stub(fs, 'existsSync').returns(true); sinon.stub(fs, 'readFileSync').returns('{"AccessToken":{}}'); diff --git a/src/auth/msalCachePlugin.ts b/src/auth/msalCachePlugin.ts index 0e8ffc7acd0..1a524dc3476 100644 --- a/src/auth/msalCachePlugin.ts +++ b/src/auth/msalCachePlugin.ts @@ -48,8 +48,12 @@ class FileCachePlugin implements ICachePlugin { } export const msalCachePlugin = { + async importMsalExtensions(): Promise { + return await import('@azure/msal-node-extensions'); + }, + async createNativePersistence(): Promise<{ plugin: ICachePlugin; clearCache: () => Promise }> { - const { DataProtectionScope, PersistenceCachePlugin, PersistenceCreator } = await import('@azure/msal-node-extensions'); + const { DataProtectionScope, PersistenceCachePlugin, PersistenceCreator } = await msalCachePlugin.importMsalExtensions(); const persistence = await PersistenceCreator.createPersistence({ ...persistenceConfiguration, dataProtectionScope: DataProtectionScope.CurrentUser @@ -60,6 +64,16 @@ export const msalCachePlugin = { }; }, + createFileFallback(): { plugin: ICachePlugin; clearCache: () => Promise } { + return { + plugin: new FileCachePlugin(persistenceConfiguration.cachePath), + clearCache: async () => { + try { fs.unlinkSync(persistenceConfiguration.cachePath); } + catch { /* file may not exist */ } + } + }; + }, + removeLegacyCache(): void { try { if (fs.existsSync(legacyCachePath)) { @@ -85,13 +99,7 @@ export const msalCachePlugin = { catch { // Fall back to file-based cache when native persistence is // unavailable (e.g. Linux without libsecret) - return { - plugin: new FileCachePlugin(persistenceConfiguration.cachePath), - clearCache: async () => { - try { fs.unlinkSync(persistenceConfiguration.cachePath); } - catch { /* file may not exist */ } - } - }; + return msalCachePlugin.createFileFallback(); } })(); const { plugin } = await _initPromise; @@ -105,13 +113,7 @@ export const msalCachePlugin = { return await msalCachePlugin.createNativePersistence(); } catch { - return { - plugin: new FileCachePlugin(persistenceConfiguration.cachePath), - clearCache: async () => { - try { fs.unlinkSync(persistenceConfiguration.cachePath); } - catch { /* file may not exist */ } - } - }; + return msalCachePlugin.createFileFallback(); } })(); const { clearCache } = await _initPromise; From 860d010f8ee687a6197a66af6744807ffcdb0627 Mon Sep 17 00:00:00 2001 From: Waldek Mastykarz Date: Wed, 10 Jun 2026 12:07:33 +0200 Subject: [PATCH 7/8] Add comment explaining why FileCachePlugin exists Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/auth/msalCachePlugin.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/auth/msalCachePlugin.ts b/src/auth/msalCachePlugin.ts index 1a524dc3476..b73319056b9 100644 --- a/src/auth/msalCachePlugin.ts +++ b/src/auth/msalCachePlugin.ts @@ -14,6 +14,15 @@ const persistenceConfiguration = { let _initPromise: Promise<{ plugin: ICachePlugin; clearCache: () => Promise }> | undefined; +// Fallback ICachePlugin that stores tokens as plain JSON on disk. +// @azure/msal-node-extensions ships a usePlaintextFileOnLinux option +// for Linux systems without libsecret, but the package's barrel export +// eagerly loads LibSecretPersistence which does `import keytar` at the +// top level. When libsecret is missing, the entire dynamic import of +// the package fails before any fallback logic can run. This class +// provides equivalent file-based persistence without depending on the +// package at all. +// See: https://github.com/AzureAD/microsoft-authentication-library-for-js/issues/7170 class FileCachePlugin implements ICachePlugin { private cachePath: string; From ae9a035719936cac070637aa6d20aa5f78db9da6 Mon Sep 17 00:00:00 2001 From: Waldek Mastykarz Date: Fri, 17 Jul 2026 09:08:34 +0200 Subject: [PATCH 8/8] Resolve merge conflicts and add fsUtil coverage tests Merge latest main into the branch and add tests for copyRecursiveSync directory branch to reach 100% coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/utils/fsUtil.spec.ts | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/utils/fsUtil.spec.ts b/src/utils/fsUtil.spec.ts index 951b9b63438..5f0bbaac30a 100644 --- a/src/utils/fsUtil.spec.ts +++ b/src/utils/fsUtil.spec.ts @@ -1,4 +1,7 @@ import assert from 'assert'; +import fs from 'fs'; +import path from 'path'; +import os from 'os'; import { fsUtil } from './fsUtil.js'; describe('utils/fsUtil', () => { @@ -6,4 +9,40 @@ describe('utils/fsUtil', () => { const result = fsUtil.getSafeFileName('file\'name.txt'); assert.strictEqual(result, 'file\'\'name.txt'); }); + + describe('copyRecursiveSync', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'fsUtil-test-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('copies a directory recursively', () => { + const srcDir = path.join(tmpDir, 'src'); + const destDir = path.join(tmpDir, 'dest'); + fs.mkdirSync(srcDir); + fs.writeFileSync(path.join(srcDir, 'file.txt'), 'hello'); + + fsUtil.copyRecursiveSync(srcDir, destDir); + + assert.strictEqual(fs.existsSync(destDir), true); + assert.strictEqual(fs.readFileSync(path.join(destDir, 'file.txt'), 'utf8'), 'hello'); + }); + + it('copies a directory recursively when destination already exists', () => { + const srcDir = path.join(tmpDir, 'src'); + const destDir = path.join(tmpDir, 'dest'); + fs.mkdirSync(srcDir); + fs.mkdirSync(destDir); + fs.writeFileSync(path.join(srcDir, 'file.txt'), 'hello'); + + fsUtil.copyRecursiveSync(srcDir, destDir); + + assert.strictEqual(fs.readFileSync(path.join(destDir, 'file.txt'), 'utf8'), 'hello'); + }); + }); }); \ No newline at end of file