@softeria/ms-365-mcp-server 0.137.0 → 0.139.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 +20 -4
- package/dist/lib/cache-encryption.js +89 -0
- package/dist/lib/config-paths.js +20 -0
- package/dist/server.js +13 -43
- package/dist/token-cache-storage.js +309 -26
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -630,11 +630,23 @@ Environment variables:
|
|
|
630
630
|
|
|
631
631
|
## Token Storage
|
|
632
632
|
|
|
633
|
-
Authentication tokens are stored
|
|
633
|
+
Authentication tokens are stored in an encrypted file (AES-256-GCM). Only the 32-byte encryption key goes to the OS credential store via keytar.
|
|
634
634
|
|
|
635
|
-
|
|
635
|
+
The cache itself is too big for some credential stores to hold - a Windows Credential Manager blob caps out at 2560 bytes and a real token cache is several times that, so on Windows the write could never succeed. A key is 32 bytes regardless of how many accounts are signed in, so this works the same way on every platform.
|
|
636
636
|
|
|
637
|
-
|
|
637
|
+
**Default paths** are in the per-user config directory:
|
|
638
|
+
|
|
639
|
+
| Platform | Location |
|
|
640
|
+
| -------- | ------------------------------------------------------------------------- |
|
|
641
|
+
| Windows | `%APPDATA%\ms-365-mcp-server\` |
|
|
642
|
+
| macOS | `~/Library/Application Support/ms-365-mcp-server/` |
|
|
643
|
+
| Linux | `$XDG_CONFIG_HOME/ms-365-mcp-server/` (or `~/.config/ms-365-mcp-server/`) |
|
|
644
|
+
|
|
645
|
+
Earlier versions defaulted to a path inside the installed package, which under `npx` resolves to a content-hashed cache directory that `npm cache clean` or a version bump throws away. A cache still sitting in the package directory is moved to the new location on first run.
|
|
646
|
+
|
|
647
|
+
That covers global and local installs, and `npx` when the hash has not changed. It cannot reach a cache left behind in a _previous_ `npx` hash directory, so upgrading an `npx` install one last time means signing in again. Adopting a cache from another directory would mean trusting a directory this package cannot prove it wrote, which is not worth one saved sign-in.
|
|
648
|
+
|
|
649
|
+
Override the paths if you need to:
|
|
638
650
|
|
|
639
651
|
```bash
|
|
640
652
|
export MS365_MCP_TOKEN_CACHE_PATH="$HOME/.config/ms365-mcp/.token-cache.json"
|
|
@@ -643,7 +655,11 @@ export MS365_MCP_SELECTED_ACCOUNT_PATH="$HOME/.config/ms365-mcp/.selected-accoun
|
|
|
643
655
|
|
|
644
656
|
Parent directories are created automatically. Files are written with `0600` permissions.
|
|
645
657
|
|
|
646
|
-
|
|
658
|
+
**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.
|
|
659
|
+
|
|
660
|
+
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.
|
|
661
|
+
|
|
662
|
+
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.
|
|
647
663
|
|
|
648
664
|
> **Hosted/sandboxed environments** (e.g. Anthropic Cowork): Set `MS365_MCP_TOKEN_CACHE_PATH` and `MS365_MCP_SELECTED_ACCOUNT_PATH` to a persistent mount so tokens survive between sessions.
|
|
649
665
|
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
|
2
|
+
const ALGORITHM = "aes-256-gcm";
|
|
3
|
+
const ALGORITHM_LABEL = "A256GCM";
|
|
4
|
+
const ENVELOPE_VERSION = 2;
|
|
5
|
+
const IV_BYTES = 12;
|
|
6
|
+
const TAG_BYTES = 16;
|
|
7
|
+
const CACHE_KEY_BYTES = 32;
|
|
8
|
+
function generateCacheKey() {
|
|
9
|
+
return randomBytes(CACHE_KEY_BYTES);
|
|
10
|
+
}
|
|
11
|
+
function parseCacheKey(encoded) {
|
|
12
|
+
const key = Buffer.from(encoded.trim(), "base64");
|
|
13
|
+
if (key.length !== CACHE_KEY_BYTES) {
|
|
14
|
+
throw new Error(`Auth cache key must be ${CACHE_KEY_BYTES} bytes, got ${key.length}`);
|
|
15
|
+
}
|
|
16
|
+
return key;
|
|
17
|
+
}
|
|
18
|
+
function encodeCacheKey(key) {
|
|
19
|
+
return key.toString("base64");
|
|
20
|
+
}
|
|
21
|
+
function additionalData(purpose) {
|
|
22
|
+
return Buffer.from(`ms365:v${ENVELOPE_VERSION}:${ALGORITHM_LABEL}:${purpose}`, "utf8");
|
|
23
|
+
}
|
|
24
|
+
function encryptCache(plaintext, key, purpose) {
|
|
25
|
+
const iv = randomBytes(IV_BYTES);
|
|
26
|
+
const cipher = createCipheriv(ALGORITHM, key, iv);
|
|
27
|
+
cipher.setAAD(additionalData(purpose));
|
|
28
|
+
const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
29
|
+
const envelope = {
|
|
30
|
+
_cacheEnvelope: true,
|
|
31
|
+
v: ENVELOPE_VERSION,
|
|
32
|
+
alg: ALGORITHM_LABEL,
|
|
33
|
+
iv: iv.toString("base64"),
|
|
34
|
+
tag: cipher.getAuthTag().toString("base64"),
|
|
35
|
+
data: ciphertext.toString("base64")
|
|
36
|
+
};
|
|
37
|
+
return JSON.stringify(envelope);
|
|
38
|
+
}
|
|
39
|
+
function isEncryptedCache(raw) {
|
|
40
|
+
return readEnvelope(raw) !== void 0;
|
|
41
|
+
}
|
|
42
|
+
function decryptCache(raw, key, purpose) {
|
|
43
|
+
const envelope = readEnvelope(raw);
|
|
44
|
+
if (!envelope) {
|
|
45
|
+
throw new Error("Auth cache is not an encrypted envelope");
|
|
46
|
+
}
|
|
47
|
+
if (envelope.v !== ENVELOPE_VERSION) {
|
|
48
|
+
throw new Error(`Unsupported auth cache envelope version: ${envelope.v}`);
|
|
49
|
+
}
|
|
50
|
+
if (envelope.alg !== ALGORITHM_LABEL) {
|
|
51
|
+
throw new Error(`Unsupported auth cache algorithm: ${envelope.alg}`);
|
|
52
|
+
}
|
|
53
|
+
if (typeof envelope.iv !== "string" || typeof envelope.tag !== "string" || typeof envelope.data !== "string") {
|
|
54
|
+
throw new Error("Auth cache envelope is missing iv, tag or data");
|
|
55
|
+
}
|
|
56
|
+
const iv = Buffer.from(envelope.iv, "base64");
|
|
57
|
+
const tag = Buffer.from(envelope.tag, "base64");
|
|
58
|
+
if (iv.length !== IV_BYTES || tag.length !== TAG_BYTES) {
|
|
59
|
+
throw new Error("Auth cache envelope has a malformed iv or tag");
|
|
60
|
+
}
|
|
61
|
+
const decipher = createDecipheriv(ALGORITHM, key, iv);
|
|
62
|
+
decipher.setAAD(additionalData(purpose));
|
|
63
|
+
decipher.setAuthTag(tag);
|
|
64
|
+
return Buffer.concat([
|
|
65
|
+
decipher.update(Buffer.from(envelope.data, "base64")),
|
|
66
|
+
decipher.final()
|
|
67
|
+
]).toString("utf8");
|
|
68
|
+
}
|
|
69
|
+
function readEnvelope(raw) {
|
|
70
|
+
let parsed;
|
|
71
|
+
try {
|
|
72
|
+
parsed = JSON.parse(raw);
|
|
73
|
+
} catch {
|
|
74
|
+
return void 0;
|
|
75
|
+
}
|
|
76
|
+
if (!parsed || typeof parsed !== "object") return void 0;
|
|
77
|
+
const candidate = parsed;
|
|
78
|
+
if (candidate._cacheEnvelope !== true || typeof candidate.v !== "number") return void 0;
|
|
79
|
+
return candidate;
|
|
80
|
+
}
|
|
81
|
+
export {
|
|
82
|
+
CACHE_KEY_BYTES,
|
|
83
|
+
decryptCache,
|
|
84
|
+
encodeCacheKey,
|
|
85
|
+
encryptCache,
|
|
86
|
+
generateCacheKey,
|
|
87
|
+
isEncryptedCache,
|
|
88
|
+
parseCacheKey
|
|
89
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
const APP_DIR_NAME = "ms-365-mcp-server";
|
|
4
|
+
function getConfigDir() {
|
|
5
|
+
if (process.platform === "win32") {
|
|
6
|
+
const appData = process.env.APPDATA?.trim();
|
|
7
|
+
return path.join(appData || path.join(os.homedir(), "AppData", "Roaming"), APP_DIR_NAME);
|
|
8
|
+
}
|
|
9
|
+
if (process.platform === "darwin") {
|
|
10
|
+
return path.join(os.homedir(), "Library", "Application Support", APP_DIR_NAME);
|
|
11
|
+
}
|
|
12
|
+
const xdg = process.env.XDG_CONFIG_HOME?.trim();
|
|
13
|
+
if (xdg && path.isAbsolute(xdg)) {
|
|
14
|
+
return path.join(xdg, APP_DIR_NAME);
|
|
15
|
+
}
|
|
16
|
+
return path.join(os.homedir(), ".config", APP_DIR_NAME);
|
|
17
|
+
}
|
|
18
|
+
export {
|
|
19
|
+
getConfigDir
|
|
20
|
+
};
|
package/dist/server.js
CHANGED
|
@@ -479,48 +479,16 @@ class MicrosoftGraphServer {
|
|
|
479
479
|
allowUnauthenticatedDiscovery: this.options.allowUnauthenticatedDiscovery,
|
|
480
480
|
publicUrl: publicBase
|
|
481
481
|
});
|
|
482
|
-
app.get(
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
res.on("close", () => {
|
|
493
|
-
transport.close();
|
|
494
|
-
server.close();
|
|
495
|
-
});
|
|
496
|
-
await server.connect(transport);
|
|
497
|
-
await transport.handleRequest(req, res, void 0);
|
|
498
|
-
};
|
|
499
|
-
try {
|
|
500
|
-
if (req.microsoftAuth) {
|
|
501
|
-
let accessToken = req.microsoftAuth.accessToken;
|
|
502
|
-
if (this.oboClient) {
|
|
503
|
-
accessToken = await this.oboClient.exchangeToken(accessToken);
|
|
504
|
-
}
|
|
505
|
-
await requestContext.run({ accessToken }, handler);
|
|
506
|
-
} else {
|
|
507
|
-
await handler();
|
|
508
|
-
}
|
|
509
|
-
} catch (error) {
|
|
510
|
-
logger.error("Error handling MCP GET request:", error);
|
|
511
|
-
if (!res.headersSent) {
|
|
512
|
-
res.status(500).json({
|
|
513
|
-
jsonrpc: "2.0",
|
|
514
|
-
error: {
|
|
515
|
-
code: -32603,
|
|
516
|
-
message: "Internal server error"
|
|
517
|
-
},
|
|
518
|
-
id: null
|
|
519
|
-
});
|
|
520
|
-
}
|
|
521
|
-
}
|
|
522
|
-
}
|
|
523
|
-
);
|
|
482
|
+
app.get("/mcp", (req, res) => {
|
|
483
|
+
res.status(405).json({
|
|
484
|
+
jsonrpc: "2.0",
|
|
485
|
+
error: {
|
|
486
|
+
code: -32e3,
|
|
487
|
+
message: "Method not allowed."
|
|
488
|
+
},
|
|
489
|
+
id: null
|
|
490
|
+
});
|
|
491
|
+
});
|
|
524
492
|
app.post(
|
|
525
493
|
"/mcp",
|
|
526
494
|
mcpAuth,
|
|
@@ -528,8 +496,10 @@ class MicrosoftGraphServer {
|
|
|
528
496
|
const handler = async () => {
|
|
529
497
|
const server = this.createMcpServer();
|
|
530
498
|
const transport = new StreamableHTTPServerTransport({
|
|
531
|
-
sessionIdGenerator: void 0
|
|
499
|
+
sessionIdGenerator: void 0,
|
|
532
500
|
// Stateless mode
|
|
501
|
+
enableJsonResponse: true
|
|
502
|
+
// Reply to POSTs with plain JSON, not one-shot SSE
|
|
533
503
|
});
|
|
534
504
|
res.on("close", () => {
|
|
535
505
|
transport.close();
|
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
2
3
|
import fs, { existsSync, readFileSync } from "node:fs";
|
|
3
4
|
import path from "node:path";
|
|
4
5
|
import { fileURLToPath } from "node:url";
|
|
6
|
+
import {
|
|
7
|
+
decryptCache,
|
|
8
|
+
encodeCacheKey,
|
|
9
|
+
encryptCache,
|
|
10
|
+
generateCacheKey,
|
|
11
|
+
isEncryptedCache,
|
|
12
|
+
parseCacheKey
|
|
13
|
+
} from "./lib/cache-encryption.js";
|
|
14
|
+
import { getConfigDir } from "./lib/config-paths.js";
|
|
5
15
|
import logger from "./logger.js";
|
|
6
16
|
const SERVICE_NAME = "ms-365-mcp-server";
|
|
7
17
|
const TOKEN_CACHE_ACCOUNT = "msal-token-cache";
|
|
@@ -11,11 +21,12 @@ const AUTH_CACHE_COMMAND_TIMEOUT_ENV = "MS365_MCP_AUTH_CACHE_COMMAND_TIMEOUT_MS"
|
|
|
11
21
|
const DEFAULT_AUTH_CACHE_COMMAND_TIMEOUT_MS = 1e4;
|
|
12
22
|
const STDERR_LIMIT = 2048;
|
|
13
23
|
const COMMAND_KILL_GRACE_MS = 1e3;
|
|
24
|
+
const CACHE_KEY_ACCOUNT = "cache-key";
|
|
25
|
+
const TOKEN_CACHE_FILE = ".token-cache.json";
|
|
26
|
+
const SELECTED_ACCOUNT_FILE = ".selected-account.json";
|
|
27
|
+
const CACHE_KEY_FILE = ".cache-key";
|
|
14
28
|
const __filename = fileURLToPath(import.meta.url);
|
|
15
|
-
const
|
|
16
|
-
const FALLBACK_DIR = __dirname;
|
|
17
|
-
const DEFAULT_TOKEN_CACHE_PATH = path.join(FALLBACK_DIR, "..", ".token-cache.json");
|
|
18
|
-
const DEFAULT_SELECTED_ACCOUNT_PATH = path.join(FALLBACK_DIR, "..", ".selected-account.json");
|
|
29
|
+
const LEGACY_DIR = path.join(path.dirname(__filename), "..");
|
|
19
30
|
let keytar = null;
|
|
20
31
|
async function getKeytar() {
|
|
21
32
|
if (keytar === void 0) {
|
|
@@ -64,11 +75,68 @@ function pickNewestRaw(keytarRaw, fileRaw) {
|
|
|
64
75
|
}
|
|
65
76
|
function getTokenCachePath() {
|
|
66
77
|
const envPath = process.env.MS365_MCP_TOKEN_CACHE_PATH?.trim();
|
|
67
|
-
return envPath ||
|
|
78
|
+
return envPath || path.join(getConfigDir(), TOKEN_CACHE_FILE);
|
|
68
79
|
}
|
|
69
80
|
function getSelectedAccountPath() {
|
|
70
81
|
const envPath = process.env.MS365_MCP_SELECTED_ACCOUNT_PATH?.trim();
|
|
71
|
-
return envPath ||
|
|
82
|
+
return envPath || path.join(getConfigDir(), SELECTED_ACCOUNT_FILE);
|
|
83
|
+
}
|
|
84
|
+
function getCacheKeyPath() {
|
|
85
|
+
return path.join(path.dirname(getTokenCachePath()), CACHE_KEY_FILE);
|
|
86
|
+
}
|
|
87
|
+
let legacyPathsMigrated = false;
|
|
88
|
+
function migrateLegacyPaths() {
|
|
89
|
+
if (legacyPathsMigrated) return;
|
|
90
|
+
legacyPathsMigrated = true;
|
|
91
|
+
migrateLegacyPathsFrom(LEGACY_DIR);
|
|
92
|
+
}
|
|
93
|
+
function migrateLegacyPathsFrom(legacyDir) {
|
|
94
|
+
const moves = [
|
|
95
|
+
[TOKEN_CACHE_FILE, getTokenCachePath(), process.env.MS365_MCP_TOKEN_CACHE_PATH?.trim()],
|
|
96
|
+
[
|
|
97
|
+
SELECTED_ACCOUNT_FILE,
|
|
98
|
+
getSelectedAccountPath(),
|
|
99
|
+
process.env.MS365_MCP_SELECTED_ACCOUNT_PATH?.trim()
|
|
100
|
+
]
|
|
101
|
+
];
|
|
102
|
+
for (const [fileName, target, envOverride] of moves) {
|
|
103
|
+
if (envOverride || existsSync(target)) continue;
|
|
104
|
+
const legacyPath = path.join(legacyDir, fileName);
|
|
105
|
+
if (!existsSync(legacyPath) || existsSync(`${legacyPath}.migrated`)) continue;
|
|
106
|
+
let moved = false;
|
|
107
|
+
try {
|
|
108
|
+
ensureParentDir(target);
|
|
109
|
+
fs.renameSync(legacyPath, target);
|
|
110
|
+
moved = true;
|
|
111
|
+
} catch {
|
|
112
|
+
try {
|
|
113
|
+
ensureParentDir(target);
|
|
114
|
+
fs.copyFileSync(legacyPath, target);
|
|
115
|
+
moved = true;
|
|
116
|
+
try {
|
|
117
|
+
fs.unlinkSync(legacyPath);
|
|
118
|
+
} catch {
|
|
119
|
+
logger.warn(`Copied auth cache to ${target} but could not remove ${legacyPath}`);
|
|
120
|
+
try {
|
|
121
|
+
fs.writeFileSync(`${legacyPath}.migrated`, "", { mode: 384 });
|
|
122
|
+
} catch {
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
} catch (copyError) {
|
|
126
|
+
logger.warn(
|
|
127
|
+
`Could not migrate auth cache ${fileName} to ${target}: ${copyError.message}`
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (!moved) continue;
|
|
132
|
+
if (tightenPermissions(target)) {
|
|
133
|
+
logger.info(`Moved auth cache ${fileName} out of the package directory to ${target}`);
|
|
134
|
+
} else {
|
|
135
|
+
logger.error(
|
|
136
|
+
`Moved auth cache ${fileName} to ${target}, but could not make it owner-only. It holds credentials in plaintext until the next save re-encrypts it - restrict it by hand, or delete it and sign in again.`
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
72
140
|
}
|
|
73
141
|
function storageAccountForKey(key) {
|
|
74
142
|
assertValidKey(key);
|
|
@@ -83,56 +151,268 @@ function assertValidKey(key) {
|
|
|
83
151
|
throw new Error(`Unknown auth cache storage key: ${String(key)}`);
|
|
84
152
|
}
|
|
85
153
|
}
|
|
154
|
+
function tightenPermissions(filePath) {
|
|
155
|
+
try {
|
|
156
|
+
fs.chmodSync(filePath, 384);
|
|
157
|
+
return true;
|
|
158
|
+
} catch (error) {
|
|
159
|
+
logger.warn(`Could not restrict permissions on ${filePath}: ${error.message}`);
|
|
160
|
+
return process.platform === "win32";
|
|
161
|
+
}
|
|
162
|
+
}
|
|
86
163
|
function ensureParentDir(filePath) {
|
|
87
164
|
const dir = path.dirname(filePath);
|
|
88
165
|
fs.mkdirSync(dir, { recursive: true, mode: 448 });
|
|
89
166
|
}
|
|
90
167
|
function writeFileAtomically(filePath, value) {
|
|
91
168
|
ensureParentDir(filePath);
|
|
92
|
-
const tempPath =
|
|
93
|
-
path.dirname(filePath),
|
|
94
|
-
`.${path.basename(filePath)}.${process.pid}.${Date.now()}.tmp`
|
|
95
|
-
);
|
|
169
|
+
const tempPath = tempPathFor(filePath);
|
|
96
170
|
fs.writeFileSync(tempPath, value, { mode: 384 });
|
|
97
171
|
fs.renameSync(tempPath, filePath);
|
|
98
172
|
}
|
|
173
|
+
let keyStatePromise;
|
|
174
|
+
let encryptionKeyPromise;
|
|
175
|
+
const legacyKeytarEntries = /* @__PURE__ */ new Set();
|
|
176
|
+
const warnedUndecryptable = /* @__PURE__ */ new Set();
|
|
177
|
+
function resetCacheKeyForTests() {
|
|
178
|
+
keyStatePromise = void 0;
|
|
179
|
+
encryptionKeyPromise = void 0;
|
|
180
|
+
legacyKeytarEntries.clear();
|
|
181
|
+
warnedUndecryptable.clear();
|
|
182
|
+
legacyPathsMigrated = false;
|
|
183
|
+
}
|
|
184
|
+
async function clearLegacyKeytarEntry(key) {
|
|
185
|
+
if (!legacyKeytarEntries.delete(key)) return;
|
|
186
|
+
try {
|
|
187
|
+
const kt = await getKeytar();
|
|
188
|
+
if (kt) {
|
|
189
|
+
await kt.deletePassword(SERVICE_NAME, storageAccountForKey(key));
|
|
190
|
+
logger.info(`Removed the pre-encryption keychain entry for ${key}`);
|
|
191
|
+
}
|
|
192
|
+
} catch (error) {
|
|
193
|
+
logger.warn(
|
|
194
|
+
`Could not remove the legacy keychain entry for ${key}: ${error.message}`
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
function loadKeyState() {
|
|
199
|
+
if (!keyStatePromise) keyStatePromise = readCacheKeys();
|
|
200
|
+
return keyStatePromise;
|
|
201
|
+
}
|
|
202
|
+
async function readCacheKeys() {
|
|
203
|
+
const keys = [];
|
|
204
|
+
let canUseKeychain = false;
|
|
205
|
+
const kt = await getKeytar();
|
|
206
|
+
if (kt) {
|
|
207
|
+
let stored = null;
|
|
208
|
+
try {
|
|
209
|
+
stored = await kt.getPassword(SERVICE_NAME, CACHE_KEY_ACCOUNT);
|
|
210
|
+
canUseKeychain = true;
|
|
211
|
+
} catch (error) {
|
|
212
|
+
logger.warn(`Keychain access failed for the auth cache key: ${error.message}`);
|
|
213
|
+
}
|
|
214
|
+
if (stored) {
|
|
215
|
+
try {
|
|
216
|
+
keys.push(parseCacheKey(stored));
|
|
217
|
+
} catch (error) {
|
|
218
|
+
logger.warn(
|
|
219
|
+
`Ignoring unusable auth cache key in the keychain: ${error.message}`
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
let fileKeyIndex;
|
|
225
|
+
const keyPath = getCacheKeyPath();
|
|
226
|
+
if (existsSync(keyPath)) {
|
|
227
|
+
try {
|
|
228
|
+
keys.push(parseCacheKey(readFileSync(keyPath, "utf8")));
|
|
229
|
+
fileKeyIndex = keys.length - 1;
|
|
230
|
+
} catch (error) {
|
|
231
|
+
logger.warn(`Ignoring unusable auth cache key file: ${error.message}`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return { keys, fileKeyIndex, canUseKeychain };
|
|
235
|
+
}
|
|
236
|
+
function getEncryptionKey() {
|
|
237
|
+
if (!encryptionKeyPromise) {
|
|
238
|
+
encryptionKeyPromise = resolveEncryptionKey().catch((error) => {
|
|
239
|
+
encryptionKeyPromise = void 0;
|
|
240
|
+
keyStatePromise = void 0;
|
|
241
|
+
throw error;
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
return encryptionKeyPromise;
|
|
245
|
+
}
|
|
246
|
+
async function resolveEncryptionKey() {
|
|
247
|
+
const state = await loadKeyState();
|
|
248
|
+
const preferred = state.keys[state.fileKeyIndex ?? 0];
|
|
249
|
+
if (preferred) return preferred;
|
|
250
|
+
const minted = await persistCacheKey(generateCacheKey(), state.canUseKeychain);
|
|
251
|
+
state.keys.unshift(minted);
|
|
252
|
+
if (state.fileKeyIndex !== void 0) state.fileKeyIndex += 1;
|
|
253
|
+
return minted;
|
|
254
|
+
}
|
|
255
|
+
async function persistCacheKey(key, canUseKeychain) {
|
|
256
|
+
const encoded = encodeCacheKey(key);
|
|
257
|
+
if (canUseKeychain) {
|
|
258
|
+
const kt = await getKeytar();
|
|
259
|
+
if (kt) {
|
|
260
|
+
try {
|
|
261
|
+
await kt.setPassword(SERVICE_NAME, CACHE_KEY_ACCOUNT, encoded);
|
|
262
|
+
const winner = await kt.getPassword(SERVICE_NAME, CACHE_KEY_ACCOUNT);
|
|
263
|
+
if (winner && winner !== encoded) {
|
|
264
|
+
logger.info("Another process stored an auth cache key first, adopting it");
|
|
265
|
+
return parseCacheKey(winner);
|
|
266
|
+
}
|
|
267
|
+
logger.info("Stored a new auth cache key in the system keychain");
|
|
268
|
+
return key;
|
|
269
|
+
} catch (error) {
|
|
270
|
+
logger.warn(`Keychain save failed for the auth cache key: ${error.message}`);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
const keyPath = getCacheKeyPath();
|
|
275
|
+
if (createFileExclusively(keyPath, encoded)) {
|
|
276
|
+
logger.info(
|
|
277
|
+
`No usable system keychain; auth cache key written to ${keyPath}. Encryption guards against casual disclosure only when the key sits beside the cache.`
|
|
278
|
+
);
|
|
279
|
+
return key;
|
|
280
|
+
}
|
|
281
|
+
try {
|
|
282
|
+
const existing = parseCacheKey(readFileSync(keyPath, "utf8"));
|
|
283
|
+
logger.info("Another process created the auth cache key file first, adopting it");
|
|
284
|
+
return existing;
|
|
285
|
+
} catch (parseError) {
|
|
286
|
+
logger.warn(
|
|
287
|
+
`Replacing an unusable auth cache key file at ${keyPath}: ${parseError.message}`
|
|
288
|
+
);
|
|
289
|
+
try {
|
|
290
|
+
fs.unlinkSync(keyPath);
|
|
291
|
+
} catch {
|
|
292
|
+
}
|
|
293
|
+
if (createFileExclusively(keyPath, encoded)) return key;
|
|
294
|
+
return parseCacheKey(readFileSync(keyPath, "utf8"));
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
function createFileExclusively(filePath, contents) {
|
|
298
|
+
ensureParentDir(filePath);
|
|
299
|
+
const tempPath = tempPathFor(filePath);
|
|
300
|
+
try {
|
|
301
|
+
fs.writeFileSync(tempPath, contents, { mode: 384 });
|
|
302
|
+
fs.linkSync(tempPath, filePath);
|
|
303
|
+
return true;
|
|
304
|
+
} catch (error) {
|
|
305
|
+
if (error.code === "EEXIST") return false;
|
|
306
|
+
try {
|
|
307
|
+
fs.writeFileSync(filePath, contents, { mode: 384, flag: "wx" });
|
|
308
|
+
return fs.readFileSync(filePath, "utf8") === contents;
|
|
309
|
+
} catch (fallbackError) {
|
|
310
|
+
if (fallbackError.code === "EEXIST") return false;
|
|
311
|
+
throw fallbackError;
|
|
312
|
+
}
|
|
313
|
+
} finally {
|
|
314
|
+
try {
|
|
315
|
+
fs.unlinkSync(tempPath);
|
|
316
|
+
} catch {
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
function tempPathFor(filePath) {
|
|
321
|
+
return path.join(
|
|
322
|
+
path.dirname(filePath),
|
|
323
|
+
`.${path.basename(filePath)}.${randomBytes(8).toString("hex")}.tmp`
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
async function readCacheFile(key, cachePath) {
|
|
327
|
+
let onDisk;
|
|
328
|
+
try {
|
|
329
|
+
onDisk = readFileSync(cachePath, "utf8");
|
|
330
|
+
} catch (error) {
|
|
331
|
+
if (error.code === "ENOENT") return { status: "absent" };
|
|
332
|
+
return { status: "unreadable", reason: error.message };
|
|
333
|
+
}
|
|
334
|
+
if (onDisk === "") return { status: "absent" };
|
|
335
|
+
if (isEncryptedCache(onDisk)) {
|
|
336
|
+
const { keys } = await loadKeyState();
|
|
337
|
+
const decrypted = decryptWithAnyKey(onDisk, keys, key);
|
|
338
|
+
if (decrypted !== void 0) return { status: "decrypted", raw: decrypted };
|
|
339
|
+
keyStatePromise = void 0;
|
|
340
|
+
encryptionKeyPromise = void 0;
|
|
341
|
+
return { status: "unreadable", reason: "no known key opens it" };
|
|
342
|
+
}
|
|
343
|
+
if (isPlainCacheJson(onDisk)) return { status: "plaintext", raw: onDisk };
|
|
344
|
+
return { status: "unreadable", reason: "not a recognisable auth cache" };
|
|
345
|
+
}
|
|
346
|
+
function isPlainCacheJson(raw) {
|
|
347
|
+
try {
|
|
348
|
+
const parsed = JSON.parse(raw);
|
|
349
|
+
return parsed !== null && typeof parsed === "object";
|
|
350
|
+
} catch {
|
|
351
|
+
return false;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
function decryptWithAnyKey(raw, keys, purpose) {
|
|
355
|
+
for (const key of keys) {
|
|
356
|
+
try {
|
|
357
|
+
return decryptCache(raw, key, purpose);
|
|
358
|
+
} catch {
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return void 0;
|
|
362
|
+
}
|
|
363
|
+
async function assertOverwritable(key, cachePath) {
|
|
364
|
+
const state = await readCacheFile(key, cachePath);
|
|
365
|
+
if (state.status !== "unreadable") {
|
|
366
|
+
warnedUndecryptable.delete(key);
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
if (!warnedUndecryptable.has(key)) {
|
|
370
|
+
warnedUndecryptable.add(key);
|
|
371
|
+
logger.warn(
|
|
372
|
+
`Not persisting ${key}: ${cachePath} cannot be read back (${state.reason}), and overwriting it would discard whatever it holds. A locked keychain usually reads fine on the next start. If it never does, delete ${cachePath} to start over.`
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
throw new Error(`Refusing to overwrite the unreadable auth cache at ${cachePath}`);
|
|
376
|
+
}
|
|
99
377
|
class DefaultTokenCacheStorage {
|
|
100
378
|
constructor() {
|
|
101
|
-
this.description = "default (
|
|
379
|
+
this.description = "default (encrypted file)";
|
|
102
380
|
this.failClosed = false;
|
|
103
381
|
}
|
|
104
382
|
async load(key) {
|
|
105
383
|
assertValidKey(key);
|
|
384
|
+
migrateLegacyPaths();
|
|
106
385
|
let keytarRaw;
|
|
107
386
|
try {
|
|
108
387
|
const kt = await getKeytar();
|
|
109
388
|
if (kt) {
|
|
110
389
|
keytarRaw = await kt.getPassword(SERVICE_NAME, storageAccountForKey(key)) ?? void 0;
|
|
390
|
+
if (keytarRaw) legacyKeytarEntries.add(key);
|
|
111
391
|
}
|
|
112
392
|
} catch (error) {
|
|
113
393
|
logger.warn(`Keychain access failed for ${key}: ${error.message}`);
|
|
114
394
|
}
|
|
115
|
-
let fileRaw;
|
|
116
395
|
const cachePath = filePathForKey(key);
|
|
117
|
-
|
|
118
|
-
|
|
396
|
+
const state = await readCacheFile(key, cachePath);
|
|
397
|
+
let fileRaw;
|
|
398
|
+
if (state.status === "decrypted" || state.status === "plaintext") {
|
|
399
|
+
fileRaw = state.raw;
|
|
400
|
+
} else if (state.status === "unreadable") {
|
|
401
|
+
logger.warn(
|
|
402
|
+
`Could not read ${key} back (${state.reason}); leaving it in place and re-authenticating`
|
|
403
|
+
);
|
|
119
404
|
}
|
|
120
405
|
return pickNewestRaw(keytarRaw, fileRaw);
|
|
121
406
|
}
|
|
122
407
|
async save(key, value) {
|
|
123
408
|
assertValidKey(key);
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
logger.warn(
|
|
132
|
-
`Keychain save failed for ${key}, falling back to file storage: ${error.message}`
|
|
133
|
-
);
|
|
134
|
-
}
|
|
135
|
-
writeFileAtomically(filePathForKey(key), value);
|
|
409
|
+
migrateLegacyPaths();
|
|
410
|
+
const cachePath = filePathForKey(key);
|
|
411
|
+
await assertOverwritable(key, cachePath);
|
|
412
|
+
const encryptionKey = await getEncryptionKey();
|
|
413
|
+
await assertOverwritable(key, cachePath);
|
|
414
|
+
writeFileAtomically(cachePath, encryptCache(value, encryptionKey, key));
|
|
415
|
+
await clearLegacyKeytarEntry(key);
|
|
136
416
|
}
|
|
137
417
|
async delete(key) {
|
|
138
418
|
assertValidKey(key);
|
|
@@ -331,9 +611,12 @@ export {
|
|
|
331
611
|
CommandTokenCacheStorage,
|
|
332
612
|
DefaultTokenCacheStorage,
|
|
333
613
|
createTokenCacheStorage,
|
|
614
|
+
getCacheKeyPath,
|
|
334
615
|
getSelectedAccountPath,
|
|
335
616
|
getTokenCachePath,
|
|
617
|
+
migrateLegacyPathsFrom,
|
|
336
618
|
pickNewest,
|
|
619
|
+
resetCacheKeyForTests,
|
|
337
620
|
unwrapCache,
|
|
338
621
|
wrapCache
|
|
339
622
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +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.139.0",
|
|
4
5
|
"description": " A Model Context Protocol (MCP) server for interacting with Microsoft 365 and Office services through the Graph API",
|
|
5
6
|
"type": "module",
|
|
6
7
|
"main": "dist/index.js",
|