@softeria/ms-365-mcp-server 0.146.1 → 0.147.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -0
- package/dist/auth-tools.js +5 -1
- package/dist/auth.js +248 -3
- package/dist/lib/cache-dedupe.js +126 -0
- package/dist/token-cache-storage.js +77 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -667,6 +667,20 @@ Parent directories are created automatically. Files are written with `0600` perm
|
|
|
667
667
|
|
|
668
668
|
**Without a credential store** (headless Linux, most containers) the key is written to `.cache-key` next to the cache file, with `0600` permissions. That stops the tokens showing up in a stray `cat`, a backup or an accidental commit. It does not protect against anyone who can already read the directory - the key is right there. Use `MS365_MCP_AUTH_CACHE_COMMAND` below if you need the cache in a real secret store.
|
|
669
669
|
|
|
670
|
+
**Skipping the credential store on purpose:**
|
|
671
|
+
|
|
672
|
+
```bash
|
|
673
|
+
export MS365_MCP_USE_KEYTAR=0 # also accepts false, no or off
|
|
674
|
+
```
|
|
675
|
+
|
|
676
|
+
The key then goes to `.cache-key` on every platform, exactly as it does where no credential store exists, and nothing in the server calls keytar. Useful when the credential store prompts on each start - macOS re-asks whenever the calling binary changes, which under `npx` is every version bump - or when the native module misbehaves on your platform rather than simply failing to load. Any other value leaves the credential store in use, and an unrecognised one is warned about rather than passed over silently.
|
|
677
|
+
|
|
678
|
+
Switching it off strands a cache that was encrypted under a key already in the credential store, since nothing can reach that key any more. The server says so and replaces that cache on the next sign-in, which signs out **every** account it held, not just the one you sign back in as. Unset the variable first if that cache is worth keeping.
|
|
679
|
+
|
|
680
|
+
Only a cache that nothing on the machine can open is replaced. One that fails to decrypt while a usable key is sitting right there - a truncated file, a downgrade to an older build, a cache from somewhere else - is damage rather than a stranded cache, and is left alone exactly as it is by default.
|
|
681
|
+
|
|
682
|
+
Two things it deliberately does not do. It never deletes what this server already put in the credential store, on logout or otherwise, because reaching the store is the thing you just asked it to stop doing - clear the `ms-365-mcp-server` entries by hand if you want them gone. And a `.cache-key` that exists but cannot be read (wrong owner on a bind-mounted config directory, say) is treated as recoverable rather than missing: the server refuses both to overwrite a cache and to mint a replacement key, and says so, rather than deleting a key that would work again once the permissions are fixed. Fix the permissions, or delete `.cache-key` yourself to start over - which does mean signing in again.
|
|
683
|
+
|
|
670
684
|
If the cache cannot be decrypted - key lost, keychain locked, file modified - you are asked to sign in again rather than the server failing to start. The cache file is left exactly as it was: not deleted, and not overwritten by that new sign-in either. A keychain that is merely locked usually reads fine on the next start, and the cache is still there when it does.
|
|
671
685
|
|
|
672
686
|
The cost is that the new session is not saved while this lasts, so each start asks you to sign in again. If the key is genuinely gone and the cache will never open, delete `.token-cache.json` to start over - the log says so, and names the path.
|
package/dist/auth-tools.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import logger from "./logger.js";
|
|
2
3
|
import { getRequestTokens } from "./request-context.js";
|
|
3
4
|
function registerAuthTools(server, authManager) {
|
|
4
5
|
server.tool(
|
|
@@ -41,7 +42,10 @@ function registerAuthTools(server, authManager) {
|
|
|
41
42
|
};
|
|
42
43
|
}
|
|
43
44
|
const text = await new Promise((resolve, reject) => {
|
|
44
|
-
authManager.acquireTokenByDeviceCode(resolve).catch(
|
|
45
|
+
authManager.acquireTokenByDeviceCode(resolve).catch((error) => {
|
|
46
|
+
logger.error(`Device code login failed after the code was issued: ${error.message}`);
|
|
47
|
+
reject(error);
|
|
48
|
+
});
|
|
45
49
|
});
|
|
46
50
|
return {
|
|
47
51
|
content: [
|
package/dist/auth.js
CHANGED
|
@@ -5,6 +5,9 @@ import { fileURLToPath } from "url";
|
|
|
5
5
|
import path from "path";
|
|
6
6
|
import { getSecrets } from "./secrets.js";
|
|
7
7
|
import { getCloudEndpoints, getDefaultClientId } from "./cloud-config.js";
|
|
8
|
+
import {
|
|
9
|
+
dedupeRefreshTokens
|
|
10
|
+
} from "./lib/cache-dedupe.js";
|
|
8
11
|
import {
|
|
9
12
|
createTokenCacheStorage,
|
|
10
13
|
DefaultTokenCacheStorage,
|
|
@@ -31,13 +34,104 @@ function createMsalConfig(secrets) {
|
|
|
31
34
|
}
|
|
32
35
|
};
|
|
33
36
|
}
|
|
37
|
+
const warnedDuplicateRefreshTokens = /* @__PURE__ */ new Set();
|
|
38
|
+
let unresolvedDuplicateAccounts = /* @__PURE__ */ new Set();
|
|
39
|
+
function resetRefreshTokenWarningsForTests() {
|
|
40
|
+
warnedDuplicateRefreshTokens.clear();
|
|
41
|
+
unresolvedDuplicateAccounts = /* @__PURE__ */ new Set();
|
|
42
|
+
}
|
|
43
|
+
function canonicalKeyResolver(tokenCache) {
|
|
44
|
+
const storage = tokenCache?.storage;
|
|
45
|
+
const generate = storage?.generateCredentialKey;
|
|
46
|
+
if (typeof generate !== "function") return void 0;
|
|
47
|
+
return (entity) => {
|
|
48
|
+
if (!entity.home_account_id || !entity.environment || !entity.client_id) return void 0;
|
|
49
|
+
try {
|
|
50
|
+
const key = generate.call(storage, {
|
|
51
|
+
homeAccountId: entity.home_account_id,
|
|
52
|
+
environment: entity.environment,
|
|
53
|
+
credentialType: "RefreshToken",
|
|
54
|
+
clientId: entity.client_id,
|
|
55
|
+
...entity.family_id ? { familyId: entity.family_id } : {},
|
|
56
|
+
// Each of these owns a segment of the key. MSAL does not put them on refresh tokens
|
|
57
|
+
// today, but a serialized entity can carry them, and dropping them would map two
|
|
58
|
+
// different credentials onto one key - and then this deletes a live one
|
|
59
|
+
...entity.realm ? { realm: entity.realm } : {},
|
|
60
|
+
...entity.target ? { target: entity.target } : {},
|
|
61
|
+
...entity.token_type ? { tokenType: entity.token_type } : {},
|
|
62
|
+
secret: entity.secret ?? ""
|
|
63
|
+
});
|
|
64
|
+
return typeof key === "string" ? key : void 0;
|
|
65
|
+
} catch {
|
|
66
|
+
return void 0;
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
function pruneDuplicateRefreshTokens(data, tokenCache) {
|
|
71
|
+
const result = dedupeRefreshTokens(
|
|
72
|
+
data,
|
|
73
|
+
tokenCache === void 0 ? void 0 : canonicalKeyResolver(tokenCache)
|
|
74
|
+
);
|
|
75
|
+
for (const drop of result.dropped) {
|
|
76
|
+
const signature = `${drop.environment}->${drop.keptEnvironment}`;
|
|
77
|
+
if (warnedDuplicateRefreshTokens.has(signature)) continue;
|
|
78
|
+
warnedDuplicateRefreshTokens.add(signature);
|
|
79
|
+
logger.warn(
|
|
80
|
+
`Dropped a stale refresh token cached under ${drop.environment}, keeping the one under ${drop.keptEnvironment}. MSAL treats those as the same account and spends whichever it finds first, which is what strands a sign-in on a months-old token (issue #648).`
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
unresolvedDuplicateAccounts = new Set(result.ambiguousAccounts);
|
|
84
|
+
if (result.ambiguous > 0 && !warnedDuplicateRefreshTokens.has("ambiguous")) {
|
|
85
|
+
warnedDuplicateRefreshTokens.add("ambiguous");
|
|
86
|
+
logger.warn(
|
|
87
|
+
`The auth cache holds ${result.ambiguous} set(s) of duplicate refresh tokens with nothing to say which is current, so they were left alone. If silent refresh keeps failing for an account, log out and sign in again to clear them.`
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
return result;
|
|
91
|
+
}
|
|
92
|
+
function dropFromKVStore(tokenCache, dropped) {
|
|
93
|
+
if (dropped.length === 0) return;
|
|
94
|
+
const store = tokenCache.getKVStore?.();
|
|
95
|
+
if (!store) return;
|
|
96
|
+
for (const drop of dropped) {
|
|
97
|
+
delete store[drop.key];
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const PERSISTENCE_RECHECK_MS = 150;
|
|
101
|
+
function delay(ms) {
|
|
102
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
103
|
+
}
|
|
104
|
+
function persistedRefreshTokens(cacheJson, homeAccountId) {
|
|
105
|
+
let refreshTokens;
|
|
106
|
+
try {
|
|
107
|
+
const parsed = JSON.parse(cacheJson);
|
|
108
|
+
if (typeof parsed !== "object" || parsed === null) return void 0;
|
|
109
|
+
refreshTokens = parsed.RefreshToken;
|
|
110
|
+
} catch {
|
|
111
|
+
return void 0;
|
|
112
|
+
}
|
|
113
|
+
const secrets = /* @__PURE__ */ new Set();
|
|
114
|
+
if (typeof refreshTokens !== "object" || refreshTokens === null) return secrets;
|
|
115
|
+
for (const entity of Object.values(refreshTokens)) {
|
|
116
|
+
if (typeof entity !== "object" || entity === null) continue;
|
|
117
|
+
const credential = entity;
|
|
118
|
+
if (credential.home_account_id !== homeAccountId) continue;
|
|
119
|
+
if (credential.secret) secrets.add(credential.secret);
|
|
120
|
+
}
|
|
121
|
+
return secrets;
|
|
122
|
+
}
|
|
34
123
|
function buildDiskCoherencyCachePlugin(storage) {
|
|
35
124
|
return {
|
|
36
125
|
beforeCacheAccess: async (context) => {
|
|
37
126
|
try {
|
|
38
127
|
const cacheRaw = await storage.load("token-cache");
|
|
39
128
|
if (cacheRaw) {
|
|
40
|
-
|
|
129
|
+
const pruned = pruneDuplicateRefreshTokens(
|
|
130
|
+
unwrapCache(cacheRaw).data,
|
|
131
|
+
context.tokenCache
|
|
132
|
+
);
|
|
133
|
+
context.tokenCache.deserialize(pruned.data);
|
|
134
|
+
dropFromKVStore(context.tokenCache, pruned.dropped);
|
|
41
135
|
}
|
|
42
136
|
} catch (error) {
|
|
43
137
|
logger.error(`Error reloading token cache: ${error.message}`);
|
|
@@ -334,6 +428,12 @@ function describeAuthError(error) {
|
|
|
334
428
|
}
|
|
335
429
|
return error.message;
|
|
336
430
|
}
|
|
431
|
+
function duplicateRefreshTokenHint(account, unresolvedAccounts) {
|
|
432
|
+
if (!account || !unresolvedAccounts.has(account.homeAccountId)) {
|
|
433
|
+
return null;
|
|
434
|
+
}
|
|
435
|
+
return "The auth cache holds more than one refresh token for this account and nothing indicates which is current, so a stale one may be winning. Logging in again will not clear it - run logout first, then log in.";
|
|
436
|
+
}
|
|
337
437
|
const MSA_HOME_TENANT_ID = "9188040d-6c67-4c5b-b112-36a304b66dad";
|
|
338
438
|
function consumersAuthorityHint(error, account, authority) {
|
|
339
439
|
if (error instanceof AuthError && error.errorCode === "invalid_grant" && account?.tenantId === MSA_HOME_TENANT_ID && (!authority || /\/common\/?$/i.test(authority))) {
|
|
@@ -358,6 +458,7 @@ class AuthManager {
|
|
|
358
458
|
this.tokenExpiry = null;
|
|
359
459
|
this.selectedAccountId = null;
|
|
360
460
|
this.useInteractiveAuth = false;
|
|
461
|
+
this.loginPersistence = null;
|
|
361
462
|
this.expectedUsername = this.normalizeExpectedUsername(expectedAccount?.expectedUsername);
|
|
362
463
|
this.expectedHomeAccountId = this.normalizeExpectedHomeAccountId(
|
|
363
464
|
expectedAccount?.expectedHomeAccountId
|
|
@@ -380,7 +481,10 @@ class AuthManager {
|
|
|
380
481
|
try {
|
|
381
482
|
const cacheRaw = await this.storage.load("token-cache");
|
|
382
483
|
if (cacheRaw) {
|
|
383
|
-
this.msalApp.getTokenCache()
|
|
484
|
+
const tokenCache = this.msalApp.getTokenCache();
|
|
485
|
+
const pruned = pruneDuplicateRefreshTokens(unwrapCache(cacheRaw).data, tokenCache);
|
|
486
|
+
tokenCache.deserialize(pruned.data);
|
|
487
|
+
dropFromKVStore(tokenCache, pruned.dropped);
|
|
384
488
|
}
|
|
385
489
|
await this.loadSelectedAccount();
|
|
386
490
|
} catch (error) {
|
|
@@ -531,6 +635,115 @@ class AuthManager {
|
|
|
531
635
|
`Microsoft login did not return an account. Expected Microsoft account '${this.expectedAccountLabel()}'. Login was not persisted.`
|
|
532
636
|
);
|
|
533
637
|
}
|
|
638
|
+
async readPersistedRefreshTokens(homeAccountId) {
|
|
639
|
+
const cacheRaw = await this.storage.load("token-cache");
|
|
640
|
+
return cacheRaw ? persistedRefreshTokens(unwrapCache(cacheRaw).data, homeAccountId) : /* @__PURE__ */ new Set();
|
|
641
|
+
}
|
|
642
|
+
/**
|
|
643
|
+
* The cache as it stood before a sign-in, so the check afterwards can tell a write that
|
|
644
|
+
* never happened from one a sibling overwrote. The account is not known until the sign-in
|
|
645
|
+
* returns, so keep the whole blob and filter later. Best-effort.
|
|
646
|
+
*/
|
|
647
|
+
async snapshotPersistedCache() {
|
|
648
|
+
try {
|
|
649
|
+
const cacheRaw = await this.storage.load("token-cache");
|
|
650
|
+
return cacheRaw ? unwrapCache(cacheRaw).data : void 0;
|
|
651
|
+
} catch {
|
|
652
|
+
return void 0;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
/**
|
|
656
|
+
* Records why a sign-in could not be confirmed, then throws.
|
|
657
|
+
*
|
|
658
|
+
* The message has to outlive the throw. On the MCP `login` path the tool has already
|
|
659
|
+
* returned - it resolves as soon as there is a device code to show - so this rejection
|
|
660
|
+
* has nowhere to go, and `verify login` is where the user looks next. Clearing the
|
|
661
|
+
* in-memory token stops that check from answering out of memory and calling it a
|
|
662
|
+
* success (issue #648).
|
|
663
|
+
*/
|
|
664
|
+
failLoginPersistence(account, reason) {
|
|
665
|
+
const message = `Signed in as '${this.describeAccount(account)}', but ${reason}`;
|
|
666
|
+
this.loginPersistence = { homeAccountId: account.homeAccountId, message };
|
|
667
|
+
this.accessToken = null;
|
|
668
|
+
this.tokenExpiry = null;
|
|
669
|
+
logger.error(message);
|
|
670
|
+
throw new Error(message);
|
|
671
|
+
}
|
|
672
|
+
/** Refresh token secrets MSAL holds for an account right now, before anything reloads. */
|
|
673
|
+
inMemoryRefreshTokens(homeAccountId) {
|
|
674
|
+
const secrets = /* @__PURE__ */ new Set();
|
|
675
|
+
const cache = this.msalApp.getTokenCache();
|
|
676
|
+
const store = cache.getKVStore?.();
|
|
677
|
+
if (!store) return secrets;
|
|
678
|
+
for (const entity of Object.values(store)) {
|
|
679
|
+
if (typeof entity !== "object" || entity === null) continue;
|
|
680
|
+
const credential = entity;
|
|
681
|
+
if (credential.credentialType !== "RefreshToken") continue;
|
|
682
|
+
if (credential.homeAccountId !== homeAccountId) continue;
|
|
683
|
+
if (credential.secret) secrets.add(credential.secret);
|
|
684
|
+
}
|
|
685
|
+
return secrets;
|
|
686
|
+
}
|
|
687
|
+
/**
|
|
688
|
+
* Confirms the refresh token MSAL just issued actually reached the cache.
|
|
689
|
+
*
|
|
690
|
+
* Persistence is best-effort by design: DefaultTokenCacheStorage is not fail-closed, so
|
|
691
|
+
* afterCacheAccess swallows a refused or failed write and the sign-in still reports
|
|
692
|
+
* success. The account then works for exactly one access token lifetime - the one held
|
|
693
|
+
* in memory - and every silent refresh afterwards falls back to whatever stale token is
|
|
694
|
+
* still on disk. Nothing surfaces that until it has been happening for weeks, by which
|
|
695
|
+
* point the stale token has aged past Entra's 90-day inactivity limit and the account is
|
|
696
|
+
* dead (issue #648). Cheaper to read the cache back once than to debug that later.
|
|
697
|
+
*
|
|
698
|
+
* `before` is the cache ahead of the sign-in. Demanding the issued token on disk is right
|
|
699
|
+
* until you remember the cache is last-writer-wins, not locked (issue #545): a sibling
|
|
700
|
+
* mid-refresh can rotate the same credential just after this one saved. That leaves a
|
|
701
|
+
* live token that isn't ours, which is a working account. The failure is a write that
|
|
702
|
+
* left no trace at all.
|
|
703
|
+
*/
|
|
704
|
+
async assertLoginPersisted(account, before) {
|
|
705
|
+
this.loginPersistence = null;
|
|
706
|
+
if (!account) return;
|
|
707
|
+
const issued = this.inMemoryRefreshTokens(account.homeAccountId);
|
|
708
|
+
if (issued.size === 0) return;
|
|
709
|
+
let persisted;
|
|
710
|
+
try {
|
|
711
|
+
persisted = await this.readPersistedRefreshTokens(account.homeAccountId);
|
|
712
|
+
if (persisted && ![...issued].every((secret) => persisted.has(secret))) {
|
|
713
|
+
await delay(PERSISTENCE_RECHECK_MS);
|
|
714
|
+
persisted = await this.readPersistedRefreshTokens(account.homeAccountId);
|
|
715
|
+
}
|
|
716
|
+
} catch (error) {
|
|
717
|
+
this.failLoginPersistence(
|
|
718
|
+
account,
|
|
719
|
+
`the auth cache could not be read back (${error.message}), so the sign-in cannot be confirmed as saved. It would stop working when this access token expires. Fix the cache location, then log in again.`
|
|
720
|
+
);
|
|
721
|
+
}
|
|
722
|
+
if (persisted === void 0) {
|
|
723
|
+
this.failLoginPersistence(
|
|
724
|
+
account,
|
|
725
|
+
`the auth cache did not read back as a usable cache, so the sign-in cannot be confirmed as saved. It would stop working when this access token expires. Check the log for 'Not persisting token-cache'.`
|
|
726
|
+
);
|
|
727
|
+
}
|
|
728
|
+
if (![...issued].every((secret) => persisted.has(secret))) {
|
|
729
|
+
const beforeSecrets = before === void 0 ? void 0 : persistedRefreshTokens(before, account.homeAccountId);
|
|
730
|
+
const supersededByAnotherProcess = beforeSecrets !== void 0 && [...persisted].some((secret) => !beforeSecrets.has(secret));
|
|
731
|
+
if (!supersededByAnotherProcess) {
|
|
732
|
+
this.failLoginPersistence(
|
|
733
|
+
account,
|
|
734
|
+
`the refresh token was not written to the auth cache, so access would stop working when this access token expires. The log says why - look for 'Not persisting token-cache' or 'Error saving token cache'.`
|
|
735
|
+
);
|
|
736
|
+
}
|
|
737
|
+
logger.warn(
|
|
738
|
+
`Another process rewrote the auth cache for '${this.describeAccount(account)}' during sign-in. The refresh token on disk is not the one just issued, but it was not there before this sign-in either, so something wrote a live credential and the sign-in counts as saved (issue #545).`
|
|
739
|
+
);
|
|
740
|
+
}
|
|
741
|
+
if (persisted.size > 1) {
|
|
742
|
+
logger.warn(
|
|
743
|
+
`The auth cache holds ${persisted.size} refresh tokens for '${this.describeAccount(account)}'. MSAL spends whichever it finds first, so a stale one can win (issue #648). Log out and sign in again if silent refresh starts failing.`
|
|
744
|
+
);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
534
747
|
async setOAuthToken(token) {
|
|
535
748
|
this.oauthToken = token;
|
|
536
749
|
this.isOAuthMode = true;
|
|
@@ -554,7 +767,10 @@ class AuthManager {
|
|
|
554
767
|
this.tokenExpiry = response.expiresOn ? new Date(response.expiresOn).getTime() : null;
|
|
555
768
|
return this.accessToken;
|
|
556
769
|
} catch (error) {
|
|
557
|
-
const hint =
|
|
770
|
+
const hint = [
|
|
771
|
+
consumersAuthorityHint(error, currentAccount, this.config.auth.authority),
|
|
772
|
+
duplicateRefreshTokenHint(currentAccount, unresolvedDuplicateAccounts)
|
|
773
|
+
].filter(Boolean).join(" ") || null;
|
|
558
774
|
logger.error(
|
|
559
775
|
`Silent token acquisition failed: ${describeAuthError(error)}${hint ? ` ${hint}` : ""}`
|
|
560
776
|
);
|
|
@@ -602,12 +818,14 @@ class AuthManager {
|
|
|
602
818
|
try {
|
|
603
819
|
logger.info("Requesting device code...");
|
|
604
820
|
logger.info(`Requesting scopes: ${this.scopes.join(", ")}`);
|
|
821
|
+
const before = await this.snapshotPersistedCache();
|
|
605
822
|
const response = await this.msalApp.acquireTokenByDeviceCode(deviceCodeRequest);
|
|
606
823
|
logger.info(`Granted scopes: ${response?.scopes?.join(", ") || "none"}`);
|
|
607
824
|
logger.info("Device code login successful");
|
|
608
825
|
this.accessToken = response?.accessToken || null;
|
|
609
826
|
this.tokenExpiry = response?.expiresOn ? new Date(response.expiresOn).getTime() : null;
|
|
610
827
|
await this.rejectUnexpectedLoginAccount(response?.account);
|
|
828
|
+
await this.assertLoginPersisted(response?.account, before);
|
|
611
829
|
if (!this.selectedAccountId && response?.account) {
|
|
612
830
|
this.selectedAccountId = response.account.homeAccountId;
|
|
613
831
|
await this.saveSelectedAccount();
|
|
@@ -643,12 +861,14 @@ class AuthManager {
|
|
|
643
861
|
try {
|
|
644
862
|
logger.info("Requesting interactive browser login...");
|
|
645
863
|
logger.info(`Requesting scopes: ${this.scopes.join(", ")}`);
|
|
864
|
+
const before = await this.snapshotPersistedCache();
|
|
646
865
|
const response = await this.msalApp.acquireTokenInteractive(interactiveRequest);
|
|
647
866
|
logger.info(`Granted scopes: ${response?.scopes?.join(", ") || "none"}`);
|
|
648
867
|
logger.info("Interactive browser login successful");
|
|
649
868
|
this.accessToken = response?.accessToken || null;
|
|
650
869
|
this.tokenExpiry = response?.expiresOn ? new Date(response.expiresOn).getTime() : null;
|
|
651
870
|
await this.rejectUnexpectedLoginAccount(response?.account);
|
|
871
|
+
await this.assertLoginPersisted(response?.account, before);
|
|
652
872
|
if (!this.selectedAccountId && response?.account) {
|
|
653
873
|
this.selectedAccountId = response.account.homeAccountId;
|
|
654
874
|
await this.saveSelectedAccount();
|
|
@@ -660,9 +880,25 @@ class AuthManager {
|
|
|
660
880
|
throw error;
|
|
661
881
|
}
|
|
662
882
|
}
|
|
883
|
+
/**
|
|
884
|
+
* Whether the recorded failure is about the account this check would test.
|
|
885
|
+
*
|
|
886
|
+
* An explicit selection is the only thing that says "not asking about that sign-in".
|
|
887
|
+
* Resolving the account is not: a failed sign-in never reaches the auto-select and its
|
|
888
|
+
* account is usually missing from the cache anyway, so getCurrentAccount falls back to
|
|
889
|
+
* whichever is first and gives the neighbour a clean bill of health (issue #648).
|
|
890
|
+
*/
|
|
891
|
+
loginPersistenceAppliesToCurrentAccount() {
|
|
892
|
+
if (!this.loginPersistence) return false;
|
|
893
|
+
return this.selectedAccountId === null || this.selectedAccountId === this.loginPersistence.homeAccountId;
|
|
894
|
+
}
|
|
663
895
|
async testLogin() {
|
|
664
896
|
try {
|
|
665
897
|
logger.info("Testing login...");
|
|
898
|
+
if (this.loginPersistence && this.loginPersistenceAppliesToCurrentAccount()) {
|
|
899
|
+
logger.error(`Login test failed - ${this.loginPersistence.message}`);
|
|
900
|
+
return { success: false, message: this.loginPersistence.message };
|
|
901
|
+
}
|
|
666
902
|
const token = await this.getToken();
|
|
667
903
|
if (!token) {
|
|
668
904
|
logger.error("Login test failed - no token received");
|
|
@@ -723,6 +959,7 @@ class AuthManager {
|
|
|
723
959
|
this.accessToken = null;
|
|
724
960
|
this.tokenExpiry = null;
|
|
725
961
|
this.selectedAccountId = null;
|
|
962
|
+
this.loginPersistence = null;
|
|
726
963
|
await this.storage.delete("token-cache");
|
|
727
964
|
await this.storage.delete("selected-account");
|
|
728
965
|
return true;
|
|
@@ -746,6 +983,9 @@ class AuthManager {
|
|
|
746
983
|
await this.saveSelectedAccount();
|
|
747
984
|
this.accessToken = null;
|
|
748
985
|
this.tokenExpiry = null;
|
|
986
|
+
if (this.loginPersistence && this.loginPersistence.homeAccountId !== account.homeAccountId) {
|
|
987
|
+
this.loginPersistence = null;
|
|
988
|
+
}
|
|
749
989
|
logger.info(`Selected account: ${account.username} (${account.homeAccountId})`);
|
|
750
990
|
return true;
|
|
751
991
|
}
|
|
@@ -753,6 +993,9 @@ class AuthManager {
|
|
|
753
993
|
const account = await this.resolveAccount(identifier);
|
|
754
994
|
try {
|
|
755
995
|
await this.msalApp.getTokenCache().removeAccount(account);
|
|
996
|
+
if (this.loginPersistence?.homeAccountId === account.homeAccountId) {
|
|
997
|
+
this.loginPersistence = null;
|
|
998
|
+
}
|
|
756
999
|
if (this.selectedAccountId === account.homeAccountId) {
|
|
757
1000
|
this.selectedAccountId = null;
|
|
758
1001
|
await this.saveSelectedAccount();
|
|
@@ -891,6 +1134,7 @@ export {
|
|
|
891
1134
|
consumersAuthorityHint,
|
|
892
1135
|
auth_default as default,
|
|
893
1136
|
describeAuthError,
|
|
1137
|
+
duplicateRefreshTokenHint,
|
|
894
1138
|
getEndpointRequiredScopes,
|
|
895
1139
|
getEndpointScopeGroups,
|
|
896
1140
|
getMissingAllowedScopes,
|
|
@@ -899,6 +1143,7 @@ export {
|
|
|
899
1143
|
getTokenCachePath,
|
|
900
1144
|
parseAllowedScopes,
|
|
901
1145
|
pickNewest,
|
|
1146
|
+
resetRefreshTokenWarningsForTests,
|
|
902
1147
|
resolveAuthScopes,
|
|
903
1148
|
unwrapCache,
|
|
904
1149
|
wrapCache
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
function isDict(value) {
|
|
2
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3
|
+
}
|
|
4
|
+
function isEntity(value) {
|
|
5
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
6
|
+
}
|
|
7
|
+
function groupKey(homeAccountId, owner) {
|
|
8
|
+
return `${homeAccountId} ${owner}`;
|
|
9
|
+
}
|
|
10
|
+
function environmentPreferences(cache) {
|
|
11
|
+
const byOwner = /* @__PURE__ */ new Map();
|
|
12
|
+
if (isDict(cache.AccessToken)) {
|
|
13
|
+
for (const entity of Object.values(cache.AccessToken)) {
|
|
14
|
+
const home = entity?.home_account_id;
|
|
15
|
+
const environment = entity?.environment;
|
|
16
|
+
const client = entity?.client_id;
|
|
17
|
+
if (!home || !environment || !client) continue;
|
|
18
|
+
const cachedAt = Number(entity.cached_at);
|
|
19
|
+
if (!Number.isFinite(cachedAt)) continue;
|
|
20
|
+
const key = groupKey(home, client);
|
|
21
|
+
const best = byOwner.get(key);
|
|
22
|
+
if (!best || cachedAt > best.cachedAt) {
|
|
23
|
+
byOwner.set(key, { environment, cachedAt, tied: false });
|
|
24
|
+
} else if (cachedAt === best.cachedAt && environment !== best.environment) {
|
|
25
|
+
best.tied = true;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return byOwner;
|
|
30
|
+
}
|
|
31
|
+
function preferredEnvironment(preferences, group) {
|
|
32
|
+
const choice = preferences.get(groupKey(group.homeAccountId, group.owner));
|
|
33
|
+
if (!choice || choice.tied) return void 0;
|
|
34
|
+
return choice.environment;
|
|
35
|
+
}
|
|
36
|
+
function dropSupersededKeyFormats(refreshTokens, canonicalKeyFor) {
|
|
37
|
+
const byCanonical = /* @__PURE__ */ new Map();
|
|
38
|
+
for (const [key, entity] of Object.entries(refreshTokens)) {
|
|
39
|
+
if (!isEntity(entity)) continue;
|
|
40
|
+
const canonical = canonicalKeyFor(entity);
|
|
41
|
+
if (!canonical) continue;
|
|
42
|
+
byCanonical.set(canonical, [...byCanonical.get(canonical) ?? [], key]);
|
|
43
|
+
}
|
|
44
|
+
const dropped = [];
|
|
45
|
+
for (const [canonical, keys] of byCanonical) {
|
|
46
|
+
if (keys.length < 2 || !keys.includes(canonical)) continue;
|
|
47
|
+
for (const key of keys) {
|
|
48
|
+
if (key === canonical) continue;
|
|
49
|
+
dropped.push({
|
|
50
|
+
key,
|
|
51
|
+
environment: refreshTokens[key]?.environment ?? "unknown",
|
|
52
|
+
keptEnvironment: refreshTokens[canonical]?.environment ?? "unknown"
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return dropped;
|
|
57
|
+
}
|
|
58
|
+
function dedupeRefreshTokens(cacheJson, canonicalKeyFor) {
|
|
59
|
+
const unchanged = {
|
|
60
|
+
data: cacheJson,
|
|
61
|
+
dropped: [],
|
|
62
|
+
ambiguous: 0,
|
|
63
|
+
ambiguousAccounts: []
|
|
64
|
+
};
|
|
65
|
+
let cache;
|
|
66
|
+
try {
|
|
67
|
+
const parsed = JSON.parse(cacheJson);
|
|
68
|
+
if (!isDict(parsed)) return unchanged;
|
|
69
|
+
cache = parsed;
|
|
70
|
+
} catch {
|
|
71
|
+
return unchanged;
|
|
72
|
+
}
|
|
73
|
+
const refreshTokens = cache.RefreshToken;
|
|
74
|
+
if (!isDict(refreshTokens)) return unchanged;
|
|
75
|
+
const supersededDrops = canonicalKeyFor ? dropSupersededKeyFormats(refreshTokens, canonicalKeyFor) : [];
|
|
76
|
+
for (const drop of supersededDrops) delete refreshTokens[drop.key];
|
|
77
|
+
const groups = /* @__PURE__ */ new Map();
|
|
78
|
+
for (const [key, entity] of Object.entries(refreshTokens)) {
|
|
79
|
+
if (!isEntity(entity)) continue;
|
|
80
|
+
const home = entity.home_account_id;
|
|
81
|
+
const owner = entity.family_id || entity.client_id;
|
|
82
|
+
if (!home || !owner) continue;
|
|
83
|
+
const group = groups.get(groupKey(home, owner));
|
|
84
|
+
if (group) group.keys.push(key);
|
|
85
|
+
else groups.set(groupKey(home, owner), { homeAccountId: home, owner, keys: [key] });
|
|
86
|
+
}
|
|
87
|
+
const duplicated = [...groups.values()].filter((group) => group.keys.length > 1);
|
|
88
|
+
if (duplicated.length === 0) {
|
|
89
|
+
return supersededDrops.length > 0 ? {
|
|
90
|
+
data: JSON.stringify(cache),
|
|
91
|
+
dropped: supersededDrops,
|
|
92
|
+
ambiguous: 0,
|
|
93
|
+
ambiguousAccounts: []
|
|
94
|
+
} : unchanged;
|
|
95
|
+
}
|
|
96
|
+
const preferences = environmentPreferences(cache);
|
|
97
|
+
const dropped = [...supersededDrops];
|
|
98
|
+
let ambiguous = 0;
|
|
99
|
+
const ambiguousAccounts = /* @__PURE__ */ new Set();
|
|
100
|
+
for (const group of duplicated) {
|
|
101
|
+
const keep = preferredEnvironment(preferences, group);
|
|
102
|
+
const winners = keep ? group.keys.filter((key) => refreshTokens[key]?.environment === keep) : [];
|
|
103
|
+
if (!keep || winners.length !== 1) {
|
|
104
|
+
ambiguous += 1;
|
|
105
|
+
ambiguousAccounts.add(group.homeAccountId);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
for (const key of group.keys) {
|
|
109
|
+
if (key === winners[0]) continue;
|
|
110
|
+
dropped.push({
|
|
111
|
+
key,
|
|
112
|
+
environment: refreshTokens[key]?.environment ?? "unknown",
|
|
113
|
+
keptEnvironment: keep
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const unranked = [...ambiguousAccounts];
|
|
118
|
+
if (dropped.length === 0) {
|
|
119
|
+
return { data: cacheJson, dropped, ambiguous, ambiguousAccounts: unranked };
|
|
120
|
+
}
|
|
121
|
+
for (const drop of dropped) delete refreshTokens[drop.key];
|
|
122
|
+
return { data: JSON.stringify(cache), dropped, ambiguous, ambiguousAccounts: unranked };
|
|
123
|
+
}
|
|
124
|
+
export {
|
|
125
|
+
dedupeRefreshTokens
|
|
126
|
+
};
|
|
@@ -18,6 +18,7 @@ const TOKEN_CACHE_ACCOUNT = "msal-token-cache";
|
|
|
18
18
|
const SELECTED_ACCOUNT_KEY = "selected-account";
|
|
19
19
|
const AUTH_CACHE_COMMAND_ENV = "MS365_MCP_AUTH_CACHE_COMMAND";
|
|
20
20
|
const AUTH_CACHE_COMMAND_TIMEOUT_ENV = "MS365_MCP_AUTH_CACHE_COMMAND_TIMEOUT_MS";
|
|
21
|
+
const USE_KEYTAR_ENV = "MS365_MCP_USE_KEYTAR";
|
|
21
22
|
const DEFAULT_AUTH_CACHE_COMMAND_TIMEOUT_MS = 1e4;
|
|
22
23
|
const STDERR_LIMIT = 2048;
|
|
23
24
|
const COMMAND_KILL_GRACE_MS = 1e3;
|
|
@@ -28,7 +29,33 @@ const CACHE_KEY_FILE = ".cache-key";
|
|
|
28
29
|
const __filename = fileURLToPath(import.meta.url);
|
|
29
30
|
const LEGACY_DIR = path.join(path.dirname(__filename), "..");
|
|
30
31
|
let keytar = null;
|
|
32
|
+
let loggedKeytarOptOut = false;
|
|
33
|
+
let warnedKeytarValue = false;
|
|
34
|
+
const KEYTAR_OFF_VALUES = /* @__PURE__ */ new Set(["0", "false", "no", "off"]);
|
|
35
|
+
const KEYTAR_ON_VALUES = /* @__PURE__ */ new Set(["1", "true", "yes", "on"]);
|
|
36
|
+
function keytarEnabled() {
|
|
37
|
+
const raw = process.env[USE_KEYTAR_ENV];
|
|
38
|
+
if (raw === void 0) return true;
|
|
39
|
+
const value = raw.trim().toLowerCase();
|
|
40
|
+
if (KEYTAR_OFF_VALUES.has(value)) return false;
|
|
41
|
+
if (value !== "" && !KEYTAR_ON_VALUES.has(value) && !warnedKeytarValue) {
|
|
42
|
+
warnedKeytarValue = true;
|
|
43
|
+
logger.warn(
|
|
44
|
+
`${USE_KEYTAR_ENV} is set to ${JSON.stringify(raw)}, which is not a value this understands, so the system credential store stays in use. Set it to one of ${[...KEYTAR_OFF_VALUES].join(", ")} to turn it off.`
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
31
49
|
async function getKeytar() {
|
|
50
|
+
if (!keytarEnabled()) {
|
|
51
|
+
if (!loggedKeytarOptOut) {
|
|
52
|
+
loggedKeytarOptOut = true;
|
|
53
|
+
logger.info(
|
|
54
|
+
`${USE_KEYTAR_ENV} is off, using file-based credential storage. Anything this server previously stored under "${SERVICE_NAME}" in the system credential store is now left alone, including on logout, so clear it by hand if you want it gone.`
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
32
59
|
if (keytar === void 0) {
|
|
33
60
|
return null;
|
|
34
61
|
}
|
|
@@ -172,6 +199,7 @@ function writeFileAtomically(filePath, value) {
|
|
|
172
199
|
}
|
|
173
200
|
let keyStatePromise;
|
|
174
201
|
let encryptionKeyPromise;
|
|
202
|
+
const mintedKeys = [];
|
|
175
203
|
const legacyKeytarEntries = /* @__PURE__ */ new Set();
|
|
176
204
|
const warnedUndecryptable = /* @__PURE__ */ new Set();
|
|
177
205
|
function resetCacheKeyForTests() {
|
|
@@ -180,6 +208,9 @@ function resetCacheKeyForTests() {
|
|
|
180
208
|
legacyKeytarEntries.clear();
|
|
181
209
|
warnedUndecryptable.clear();
|
|
182
210
|
legacyPathsMigrated = false;
|
|
211
|
+
loggedKeytarOptOut = false;
|
|
212
|
+
warnedKeytarValue = false;
|
|
213
|
+
mintedKeys.length = 0;
|
|
183
214
|
}
|
|
184
215
|
async function clearLegacyKeytarEntry(key) {
|
|
185
216
|
if (!legacyKeytarEntries.delete(key)) return;
|
|
@@ -222,16 +253,26 @@ async function readCacheKeys() {
|
|
|
222
253
|
}
|
|
223
254
|
}
|
|
224
255
|
let fileKeyIndex;
|
|
256
|
+
let fileKeyUnreadable = false;
|
|
225
257
|
const keyPath = getCacheKeyPath();
|
|
226
|
-
|
|
258
|
+
let raw;
|
|
259
|
+
try {
|
|
260
|
+
raw = readFileSync(keyPath, "utf8");
|
|
261
|
+
} catch (error) {
|
|
262
|
+
if (error.code !== "ENOENT") {
|
|
263
|
+
fileKeyUnreadable = true;
|
|
264
|
+
logger.warn(`Could not read the auth cache key file: ${error.message}`);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if (raw !== void 0) {
|
|
227
268
|
try {
|
|
228
|
-
keys.push(parseCacheKey(
|
|
269
|
+
keys.push(parseCacheKey(raw));
|
|
229
270
|
fileKeyIndex = keys.length - 1;
|
|
230
271
|
} catch (error) {
|
|
231
272
|
logger.warn(`Ignoring unusable auth cache key file: ${error.message}`);
|
|
232
273
|
}
|
|
233
274
|
}
|
|
234
|
-
return { keys, fileKeyIndex, canUseKeychain };
|
|
275
|
+
return { keys, fileKeyIndex, canUseKeychain, fileKeyUnreadable };
|
|
235
276
|
}
|
|
236
277
|
function getEncryptionKey() {
|
|
237
278
|
if (!encryptionKeyPromise) {
|
|
@@ -247,7 +288,13 @@ async function resolveEncryptionKey() {
|
|
|
247
288
|
const state = await loadKeyState();
|
|
248
289
|
const preferred = state.keys[state.fileKeyIndex ?? 0];
|
|
249
290
|
if (preferred) return preferred;
|
|
291
|
+
if (state.fileKeyUnreadable) {
|
|
292
|
+
throw new Error(
|
|
293
|
+
`Refusing to replace the auth cache key at ${getCacheKeyPath()}: it exists but could not be read this run, and minting over it would strand every cache encrypted under it. Fix the permissions on that file, or delete it to start over.`
|
|
294
|
+
);
|
|
295
|
+
}
|
|
250
296
|
const minted = await persistCacheKey(generateCacheKey(), state.canUseKeychain);
|
|
297
|
+
if (!mintedKeys.some((k) => k.equals(minted))) mintedKeys.push(minted);
|
|
251
298
|
state.keys.unshift(minted);
|
|
252
299
|
if (state.fileKeyIndex !== void 0) state.fileKeyIndex += 1;
|
|
253
300
|
return minted;
|
|
@@ -278,8 +325,16 @@ async function persistCacheKey(key, canUseKeychain) {
|
|
|
278
325
|
);
|
|
279
326
|
return key;
|
|
280
327
|
}
|
|
328
|
+
let existingRaw;
|
|
329
|
+
try {
|
|
330
|
+
existingRaw = readFileSync(keyPath, "utf8");
|
|
331
|
+
} catch (readError) {
|
|
332
|
+
throw new Error(
|
|
333
|
+
`Refusing to replace the auth cache key file at ${keyPath}: it could not be read (${readError.message}), so whether it holds a usable key is unknown.`
|
|
334
|
+
);
|
|
335
|
+
}
|
|
281
336
|
try {
|
|
282
|
-
const existing = parseCacheKey(
|
|
337
|
+
const existing = parseCacheKey(existingRaw);
|
|
283
338
|
logger.info("Another process created the auth cache key file first, adopting it");
|
|
284
339
|
return existing;
|
|
285
340
|
} catch (parseError) {
|
|
@@ -333,12 +388,17 @@ async function readCacheFile(key, cachePath) {
|
|
|
333
388
|
}
|
|
334
389
|
if (onDisk === "") return { status: "absent" };
|
|
335
390
|
if (isEncryptedCache(onDisk)) {
|
|
336
|
-
const
|
|
337
|
-
const decrypted = decryptWithAnyKey(onDisk, keys, key);
|
|
391
|
+
const state = await loadKeyState();
|
|
392
|
+
const decrypted = decryptWithAnyKey(onDisk, state.keys, key);
|
|
338
393
|
if (decrypted !== void 0) return { status: "decrypted", raw: decrypted };
|
|
339
394
|
keyStatePromise = void 0;
|
|
340
395
|
encryptionKeyPromise = void 0;
|
|
341
|
-
|
|
396
|
+
const preExisting = state.keys.filter((k) => !mintedKeys.some((m) => m.equals(k)));
|
|
397
|
+
return {
|
|
398
|
+
status: "unreadable",
|
|
399
|
+
reason: state.fileKeyUnreadable ? "a key file is present but could not be read" : preExisting.length > 0 ? "the keys on hand do not open it" : "no known key opens it",
|
|
400
|
+
noKeyMatched: preExisting.length === 0 && !state.fileKeyUnreadable
|
|
401
|
+
};
|
|
342
402
|
}
|
|
343
403
|
if (isPlainCacheJson(onDisk)) return { status: "plaintext", raw: onDisk };
|
|
344
404
|
return { status: "unreadable", reason: "not a recognisable auth cache" };
|
|
@@ -366,6 +426,15 @@ async function assertOverwritable(key, cachePath) {
|
|
|
366
426
|
warnedUndecryptable.delete(key);
|
|
367
427
|
return;
|
|
368
428
|
}
|
|
429
|
+
if (state.noKeyMatched && !keytarEnabled()) {
|
|
430
|
+
if (!warnedUndecryptable.has(key)) {
|
|
431
|
+
warnedUndecryptable.add(key);
|
|
432
|
+
logger.warn(
|
|
433
|
+
`Replacing ${cachePath}: there is no auth cache key on this machine to open it with, and ${USE_KEYTAR_ENV} is off, so the key it was written under is most likely the one in the system credential store. Signing in again rewrites it against the key file instead. If that cache is worth keeping, stop the server and unset ${USE_KEYTAR_ENV} before signing in.`
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
369
438
|
if (!warnedUndecryptable.has(key)) {
|
|
370
439
|
warnedUndecryptable.add(key);
|
|
371
440
|
logger.warn(
|
|
@@ -614,6 +683,7 @@ export {
|
|
|
614
683
|
getCacheKeyPath,
|
|
615
684
|
getSelectedAccountPath,
|
|
616
685
|
getTokenCachePath,
|
|
686
|
+
keytarEnabled,
|
|
617
687
|
migrateLegacyPathsFrom,
|
|
618
688
|
pickNewest,
|
|
619
689
|
resetCacheKeyForTests,
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@softeria/ms-365-mcp-server",
|
|
3
3
|
"mcpName": "io.github.Softeria/ms-365-mcp-server",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.147.0",
|
|
5
5
|
"description": " A Model Context Protocol (MCP) server for interacting with Microsoft 365 and Office services through the Graph API",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "dist/index.js",
|