@softeria/ms-365-mcp-server 0.146.0 → 0.146.2
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/dist/__tests__/graph-tools.test.js +240 -0
- package/dist/auth-tools.js +5 -1
- package/dist/auth.js +248 -3
- package/dist/graph-tools.js +76 -21
- package/dist/lib/cache-dedupe.js +126 -0
- package/docs/deployment.md +1 -1
- package/package.json +1 -1
|
@@ -419,6 +419,246 @@ describe("graph-tools", () => {
|
|
|
419
419
|
expect(payload.recipient_domains).toEqual(["example.com"]);
|
|
420
420
|
expect(JSON.stringify(payload)).not.toContain("confidential.name");
|
|
421
421
|
});
|
|
422
|
+
it("rejects a tail that is not a plain hostname", async () => {
|
|
423
|
+
draftEndpoint();
|
|
424
|
+
const payload = await runDraft({
|
|
425
|
+
toRecipients: [
|
|
426
|
+
{ emailAddress: { address: "a@example.com/path" } },
|
|
427
|
+
{ emailAddress: { address: "b@evil<script" } },
|
|
428
|
+
{ emailAddress: { address: "c@example.com,comment" } },
|
|
429
|
+
{ emailAddress: { address: "d@[IPv6:2001:db8::1]" } },
|
|
430
|
+
{ emailAddress: { address: "e@good.example" } }
|
|
431
|
+
]
|
|
432
|
+
});
|
|
433
|
+
expect(payload.recipient_count).toBe(5);
|
|
434
|
+
expect(payload.recipient_domains).toEqual(["good.example"]);
|
|
435
|
+
});
|
|
436
|
+
it("drops a domain longer than a hostname can be", async () => {
|
|
437
|
+
draftEndpoint();
|
|
438
|
+
const payload = await runDraft({
|
|
439
|
+
toRecipients: [
|
|
440
|
+
{ emailAddress: { address: `a@${"x".repeat(300)}.example` } },
|
|
441
|
+
{ emailAddress: { address: "b@ext.com" } }
|
|
442
|
+
]
|
|
443
|
+
});
|
|
444
|
+
expect(payload.recipient_count).toBe(2);
|
|
445
|
+
expect(payload.recipient_domains).toEqual(["ext.com"]);
|
|
446
|
+
});
|
|
447
|
+
it("counts an entry that names someone without an address", async () => {
|
|
448
|
+
draftEndpoint();
|
|
449
|
+
const payload = await runDraft({
|
|
450
|
+
recipients: [{ alias: "finance-team" }, { objectId: "abc-123" }]
|
|
451
|
+
});
|
|
452
|
+
expect(payload.recipient_count).toBe(2);
|
|
453
|
+
expect(payload).not.toHaveProperty("recipient_domains");
|
|
454
|
+
});
|
|
455
|
+
it("walks a body forwarded to Graph as a raw JSON string", async () => {
|
|
456
|
+
mockEndpoints.push(
|
|
457
|
+
makeEndpoint({
|
|
458
|
+
method: "post",
|
|
459
|
+
path: "/me/sendMail",
|
|
460
|
+
alias: "send-mail",
|
|
461
|
+
parameters: [
|
|
462
|
+
{
|
|
463
|
+
name: "body",
|
|
464
|
+
type: "Body",
|
|
465
|
+
schema: z.object({ message: z.object({}).passthrough() })
|
|
466
|
+
}
|
|
467
|
+
]
|
|
468
|
+
})
|
|
469
|
+
);
|
|
470
|
+
mockEndpointsJson = [
|
|
471
|
+
makeConfig({ pathPattern: "/me/sendMail", method: "post", toolName: "send-mail" })
|
|
472
|
+
];
|
|
473
|
+
const graphClient = createMockGraphClient([
|
|
474
|
+
{
|
|
475
|
+
content: [{ type: "text", text: JSON.stringify({ ok: true }) }],
|
|
476
|
+
_meta: { http_status: 202 }
|
|
477
|
+
}
|
|
478
|
+
]);
|
|
479
|
+
const server = createMockServer();
|
|
480
|
+
const { registerGraphTools } = await loadModule();
|
|
481
|
+
registerGraphTools(
|
|
482
|
+
server,
|
|
483
|
+
graphClient
|
|
484
|
+
);
|
|
485
|
+
await server.tools.get("send-mail").handler({
|
|
486
|
+
body: JSON.stringify({
|
|
487
|
+
message: { toRecipients: [{ emailAddress: { address: "a@ext.com" } }] }
|
|
488
|
+
})
|
|
489
|
+
});
|
|
490
|
+
const payload = auditLogMock.mock.calls[0][0];
|
|
491
|
+
expect(payload).toMatchObject({ recipient_count: 1, recipient_domains: ["ext.com"] });
|
|
492
|
+
});
|
|
493
|
+
it("leaves a base64 upload body alone instead of warning on every upload", async () => {
|
|
494
|
+
mockEndpoints.push(
|
|
495
|
+
makeEndpoint({
|
|
496
|
+
method: "put",
|
|
497
|
+
path: "/me/photo/$value",
|
|
498
|
+
alias: "upload-my-profile-photo",
|
|
499
|
+
requestFormat: "binary",
|
|
500
|
+
parameters: [{ name: "body", type: "Body", schema: z.string() }]
|
|
501
|
+
})
|
|
502
|
+
);
|
|
503
|
+
mockEndpointsJson = [
|
|
504
|
+
makeConfig({
|
|
505
|
+
pathPattern: "/me/photo/$value",
|
|
506
|
+
method: "put",
|
|
507
|
+
toolName: "upload-my-profile-photo"
|
|
508
|
+
})
|
|
509
|
+
];
|
|
510
|
+
const graphClient = createMockGraphClient([
|
|
511
|
+
{ content: [{ type: "text", text: "{}" }], _meta: { http_status: 200 } }
|
|
512
|
+
]);
|
|
513
|
+
const server = createMockServer();
|
|
514
|
+
const { registerGraphTools } = await loadModule();
|
|
515
|
+
registerGraphTools(
|
|
516
|
+
server,
|
|
517
|
+
graphClient
|
|
518
|
+
);
|
|
519
|
+
await server.tools.get("upload-my-profile-photo").handler({
|
|
520
|
+
body: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"
|
|
521
|
+
});
|
|
522
|
+
const payload = auditLogMock.mock.calls[0][0];
|
|
523
|
+
expect(payload).not.toHaveProperty("recipient_count");
|
|
524
|
+
expect(loggerMock.warn).not.toHaveBeenCalledWith(
|
|
525
|
+
expect.stringContaining("Skipped recipient audit metadata")
|
|
526
|
+
);
|
|
527
|
+
});
|
|
528
|
+
it("records driveItem invite recipients, which use email rather than emailAddress", async () => {
|
|
529
|
+
draftEndpoint();
|
|
530
|
+
const payload = await runDraft({
|
|
531
|
+
recipients: [{ email: "outsider@ext.com" }],
|
|
532
|
+
roles: ["read"],
|
|
533
|
+
sendInvitation: true
|
|
534
|
+
});
|
|
535
|
+
expect(payload).toMatchObject({ recipient_count: 1, recipient_domains: ["ext.com"] });
|
|
536
|
+
});
|
|
537
|
+
it("records meeting participants, which use upn", async () => {
|
|
538
|
+
draftEndpoint();
|
|
539
|
+
const payload = await runDraft({
|
|
540
|
+
participants: { attendees: [{ upn: "guest@ext.com" }] }
|
|
541
|
+
});
|
|
542
|
+
expect(payload).toMatchObject({ recipient_count: 1, recipient_domains: ["ext.com"] });
|
|
543
|
+
});
|
|
544
|
+
it("survives a deeply nested array without blowing the stack", async () => {
|
|
545
|
+
draftEndpoint();
|
|
546
|
+
let nested = [{ toRecipients: [{ emailAddress: { address: "deep@ext.com" } }] }];
|
|
547
|
+
for (let i = 0; i < 500; i++) nested = [nested];
|
|
548
|
+
const payload = await runDraft({ requests: nested });
|
|
549
|
+
expect(payload).not.toHaveProperty("recipient_count");
|
|
550
|
+
expect(payload.status).toBe("success");
|
|
551
|
+
});
|
|
552
|
+
it("still audits a call whose params cannot be serialised for the log", async () => {
|
|
553
|
+
draftEndpoint();
|
|
554
|
+
const circular = { subject: "loop" };
|
|
555
|
+
circular.self = circular;
|
|
556
|
+
const payload = await runDraft(circular);
|
|
557
|
+
expect(auditLogMock).toHaveBeenCalledTimes(1);
|
|
558
|
+
expect(payload).toMatchObject({ tool: "create-draft-email", status: "error" });
|
|
559
|
+
});
|
|
560
|
+
it("still records recipients when the request throws", async () => {
|
|
561
|
+
draftEndpoint();
|
|
562
|
+
const graphClient = {
|
|
563
|
+
graphRequest: vi.fn().mockRejectedValue(new Error("socket hang up"))
|
|
564
|
+
};
|
|
565
|
+
const server = createMockServer();
|
|
566
|
+
const { registerGraphTools } = await loadModule();
|
|
567
|
+
registerGraphTools(
|
|
568
|
+
server,
|
|
569
|
+
graphClient
|
|
570
|
+
);
|
|
571
|
+
await server.tools.get("create-draft-email").handler({
|
|
572
|
+
body: { toRecipients: [{ emailAddress: { address: "a@ext.com" } }] }
|
|
573
|
+
});
|
|
574
|
+
const payload = auditLogMock.mock.calls[0][0];
|
|
575
|
+
expect(payload.status).toBe("error");
|
|
576
|
+
expect(payload).toMatchObject({ recipient_count: 1, recipient_domains: ["ext.com"] });
|
|
577
|
+
});
|
|
578
|
+
it("walks recipients nested inside a graph-batch sub-request", async () => {
|
|
579
|
+
draftEndpoint();
|
|
580
|
+
const payload = await runDraft({
|
|
581
|
+
requests: [
|
|
582
|
+
{ id: "1", method: "GET", url: "/me/messages?$top=5" },
|
|
583
|
+
{
|
|
584
|
+
id: "2",
|
|
585
|
+
method: "POST",
|
|
586
|
+
url: "/me/sendMail",
|
|
587
|
+
body: { message: { toRecipients: [{ emailAddress: { address: "a@ext.com" } }] } }
|
|
588
|
+
}
|
|
589
|
+
]
|
|
590
|
+
});
|
|
591
|
+
expect(payload).toMatchObject({ recipient_count: 1, recipient_domains: ["ext.com"] });
|
|
592
|
+
});
|
|
593
|
+
it("reaches an itemAttachment nested inside a graph-batch sub-request", async () => {
|
|
594
|
+
draftEndpoint();
|
|
595
|
+
const payload = await runDraft({
|
|
596
|
+
requests: [
|
|
597
|
+
{
|
|
598
|
+
id: "1",
|
|
599
|
+
method: "POST",
|
|
600
|
+
url: "/me/sendMail",
|
|
601
|
+
body: {
|
|
602
|
+
message: {
|
|
603
|
+
toRecipients: [{ emailAddress: { address: "direct@ext.com" } }],
|
|
604
|
+
attachments: [
|
|
605
|
+
{
|
|
606
|
+
"@odata.type": "#microsoft.graph.itemAttachment",
|
|
607
|
+
item: {
|
|
608
|
+
toRecipients: [{ emailAddress: { address: "forwarded@deeper.example" } }]
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
]
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
]
|
|
616
|
+
});
|
|
617
|
+
expect(payload.recipient_count).toBe(2);
|
|
618
|
+
expect(payload.recipient_domains).toEqual(["deeper.example", "ext.com"]);
|
|
619
|
+
});
|
|
620
|
+
it("reads a bare string entry, malformed though it is", async () => {
|
|
621
|
+
draftEndpoint();
|
|
622
|
+
const payload = await runDraft({ toRecipients: ["a@ext.com"] });
|
|
623
|
+
expect(payload).toMatchObject({ recipient_count: 1, recipient_domains: ["ext.com"] });
|
|
624
|
+
});
|
|
625
|
+
it("reads an all-PascalCase recipient entry, as Graph accepts it", async () => {
|
|
626
|
+
draftEndpoint();
|
|
627
|
+
const payload = await runDraft({
|
|
628
|
+
ToRecipients: [{ EmailAddress: { Address: "a@ext.com" } }]
|
|
629
|
+
});
|
|
630
|
+
expect(payload).toMatchObject({ recipient_count: 1, recipient_domains: ["ext.com"] });
|
|
631
|
+
});
|
|
632
|
+
it("normalises a display-name address down to the bare domain", async () => {
|
|
633
|
+
draftEndpoint();
|
|
634
|
+
const payload = await runDraft({
|
|
635
|
+
toRecipients: [
|
|
636
|
+
{ emailAddress: { address: "Bob <bob@ext.com>" } },
|
|
637
|
+
{ emailAddress: { address: "a@ext.com " } },
|
|
638
|
+
{ emailAddress: { address: "c@ext.com note" } }
|
|
639
|
+
]
|
|
640
|
+
});
|
|
641
|
+
expect(payload.recipient_domains).toEqual(["ext.com"]);
|
|
642
|
+
expect(payload.recipient_count).toBe(3);
|
|
643
|
+
});
|
|
644
|
+
it("caps the domain list but keeps recipient_count exact", async () => {
|
|
645
|
+
draftEndpoint();
|
|
646
|
+
const payload = await runDraft({
|
|
647
|
+
toRecipients: Array.from({ length: 60 }, (_, i) => ({
|
|
648
|
+
emailAddress: { address: `user@d${String(i).padStart(2, "0")}.example` }
|
|
649
|
+
}))
|
|
650
|
+
});
|
|
651
|
+
expect(payload.recipient_count).toBe(60);
|
|
652
|
+
expect(payload.recipient_domains).toHaveLength(50);
|
|
653
|
+
expect(payload.recipient_domains_truncated).toBe(true);
|
|
654
|
+
});
|
|
655
|
+
it("does not flag truncation when the domain list fits", async () => {
|
|
656
|
+
draftEndpoint();
|
|
657
|
+
const payload = await runDraft({
|
|
658
|
+
toRecipients: [{ emailAddress: { address: "a@example.com" } }]
|
|
659
|
+
});
|
|
660
|
+
expect(payload).not.toHaveProperty("recipient_domains_truncated");
|
|
661
|
+
});
|
|
422
662
|
it("omits both fields when a request has no recipients", async () => {
|
|
423
663
|
draftEndpoint();
|
|
424
664
|
const payload = await runDraft({ subject: "a draft with no recipients yet" });
|
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
|
package/dist/graph-tools.js
CHANGED
|
@@ -115,37 +115,84 @@ function graphResponseAuditFields(response) {
|
|
|
115
115
|
...graphBatchErrorCodeCounts !== void 0 ? { graph_batch_error_code_counts: graphBatchErrorCodeCounts } : {}
|
|
116
116
|
};
|
|
117
117
|
}
|
|
118
|
-
const RECIPIENT_FIELDS = /* @__PURE__ */ new Set([
|
|
119
|
-
|
|
120
|
-
|
|
118
|
+
const RECIPIENT_FIELDS = /* @__PURE__ */ new Set([
|
|
119
|
+
"torecipients",
|
|
120
|
+
"ccrecipients",
|
|
121
|
+
"bccrecipients",
|
|
122
|
+
"attendees",
|
|
123
|
+
// driveItem /invite mails an outsider a link to the file
|
|
124
|
+
"recipients"
|
|
125
|
+
]);
|
|
126
|
+
const MAX_BODY_DEPTH = 8;
|
|
127
|
+
const MAX_RECIPIENT_DOMAINS = 50;
|
|
128
|
+
function lookupCaseInsensitive(node, lowerName) {
|
|
129
|
+
if (!node || typeof node !== "object") return void 0;
|
|
130
|
+
for (const [key, value] of Object.entries(node)) {
|
|
131
|
+
if (key.toLowerCase() === lowerName) return value;
|
|
132
|
+
}
|
|
133
|
+
return void 0;
|
|
134
|
+
}
|
|
135
|
+
function readAddress(entry) {
|
|
136
|
+
if (typeof entry === "string") return entry;
|
|
137
|
+
const emailAddress = lookupCaseInsensitive(entry, "emailaddress");
|
|
138
|
+
const candidates = [
|
|
139
|
+
typeof emailAddress === "string" ? emailAddress : lookupCaseInsensitive(emailAddress, "address"),
|
|
140
|
+
lookupCaseInsensitive(entry, "email"),
|
|
141
|
+
lookupCaseInsensitive(entry, "upn")
|
|
142
|
+
];
|
|
143
|
+
for (const candidate of candidates) {
|
|
144
|
+
if (typeof candidate === "string" && candidate) return candidate;
|
|
145
|
+
}
|
|
146
|
+
return void 0;
|
|
147
|
+
}
|
|
148
|
+
const DOMAIN_PATTERN = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/;
|
|
149
|
+
const MAX_DOMAIN_LENGTH = 253;
|
|
150
|
+
function addRecipient(entry, domains, counter) {
|
|
151
|
+
counter.count += 1;
|
|
152
|
+
const address = readAddress(entry);
|
|
153
|
+
if (address === void 0) return;
|
|
154
|
+
const at = address.lastIndexOf("@");
|
|
155
|
+
if (at <= 0 || at >= address.length - 1) return;
|
|
156
|
+
const domain = address.slice(at + 1).trim().split(/\s/)[0].replace(/[>.]+$/, "").toLowerCase();
|
|
157
|
+
if (domain.length <= MAX_DOMAIN_LENGTH && DOMAIN_PATTERN.test(domain)) domains.add(domain);
|
|
158
|
+
}
|
|
121
159
|
function collectRecipients(node, domains, counter, depth = 0) {
|
|
122
160
|
if (!node || typeof node !== "object" || depth > MAX_BODY_DEPTH) return;
|
|
161
|
+
if (Array.isArray(node)) {
|
|
162
|
+
for (const item of node) collectRecipients(item, domains, counter, depth + 1);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
123
165
|
for (const [key, value] of Object.entries(node)) {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
collectRecipients(value, domains, counter, depth + 1);
|
|
166
|
+
if (RECIPIENT_FIELDS.has(key.toLowerCase()) && Array.isArray(value)) {
|
|
167
|
+
for (const entry of value) addRecipient(entry, domains, counter);
|
|
127
168
|
continue;
|
|
128
169
|
}
|
|
129
|
-
|
|
130
|
-
for (const entry of value) {
|
|
131
|
-
const address = entry?.emailAddress?.address;
|
|
132
|
-
if (typeof address !== "string") continue;
|
|
133
|
-
counter.count += 1;
|
|
134
|
-
const at = address.lastIndexOf("@");
|
|
135
|
-
if (at > 0 && at < address.length - 1) {
|
|
136
|
-
domains.add(address.slice(at + 1).toLowerCase());
|
|
137
|
-
}
|
|
138
|
-
}
|
|
170
|
+
collectRecipients(value, domains, counter, depth + 1);
|
|
139
171
|
}
|
|
140
172
|
}
|
|
173
|
+
function bodyForRecipientWalk(body) {
|
|
174
|
+
if (typeof body !== "string") return body;
|
|
175
|
+
if (!/^\s*[{[]/.test(body)) return void 0;
|
|
176
|
+
return JSON.parse(body);
|
|
177
|
+
}
|
|
141
178
|
function recipientAuditFields(body) {
|
|
142
179
|
const domains = /* @__PURE__ */ new Set();
|
|
143
180
|
const counter = { count: 0 };
|
|
144
|
-
|
|
181
|
+
try {
|
|
182
|
+
collectRecipients(bodyForRecipientWalk(body), domains, counter);
|
|
183
|
+
} catch (error) {
|
|
184
|
+
logger.warn(
|
|
185
|
+
`Skipped recipient audit metadata: ${error instanceof Error ? error.message : "unknown error"}`
|
|
186
|
+
);
|
|
187
|
+
return {};
|
|
188
|
+
}
|
|
145
189
|
if (counter.count === 0) return {};
|
|
190
|
+
const sorted = [...domains].sort();
|
|
191
|
+
const capped = sorted.slice(0, MAX_RECIPIENT_DOMAINS);
|
|
146
192
|
return {
|
|
147
193
|
recipient_count: counter.count,
|
|
148
|
-
...
|
|
194
|
+
...capped.length > 0 ? { recipient_domains: capped } : {},
|
|
195
|
+
...sorted.length > capped.length ? { recipient_domains_truncated: true } : {}
|
|
149
196
|
};
|
|
150
197
|
}
|
|
151
198
|
function thrownErrorAuditFields(error) {
|
|
@@ -778,8 +825,15 @@ function isPlainObject(value) {
|
|
|
778
825
|
function hasOwn(obj, key) {
|
|
779
826
|
return Object.prototype.hasOwnProperty.call(obj, key);
|
|
780
827
|
}
|
|
828
|
+
function describeParamsForLog(params) {
|
|
829
|
+
try {
|
|
830
|
+
return JSON.stringify(params);
|
|
831
|
+
} catch (error) {
|
|
832
|
+
return `[unserializable: ${error instanceof Error ? error.name : "unknown error"}]`;
|
|
833
|
+
}
|
|
834
|
+
}
|
|
781
835
|
async function executeGraphTool(tool, config, graphClient, params, authManager) {
|
|
782
|
-
logger.info(`Tool ${tool.alias} called with params: ${
|
|
836
|
+
logger.info(`Tool ${tool.alias} called with params: ${describeParamsForLog(params)}`);
|
|
783
837
|
if (isConfirmGateEnabled() && isDestructiveOperation(tool.method, config) && params.confirm !== true) {
|
|
784
838
|
logger.warn(
|
|
785
839
|
`Refusing destructive tool ${tool.alias} (${tool.method.toUpperCase()}): missing confirm: true`
|
|
@@ -805,6 +859,7 @@ async function executeGraphTool(tool, config, graphClient, params, authManager)
|
|
|
805
859
|
const upn = getUserIdentityForAudit(getRequestTokens()?.accessToken);
|
|
806
860
|
const httpMethod = tool.method.toUpperCase();
|
|
807
861
|
let targetResource;
|
|
862
|
+
let body = null;
|
|
808
863
|
try {
|
|
809
864
|
const accountParam = params.account;
|
|
810
865
|
const accountModeError = await checkAccountParamInBearerMode(accountParam, authManager);
|
|
@@ -834,7 +889,6 @@ async function executeGraphTool(tool, config, graphClient, params, authManager)
|
|
|
834
889
|
let path2 = tool.path;
|
|
835
890
|
const queryParams = {};
|
|
836
891
|
const headers = {};
|
|
837
|
-
let body = null;
|
|
838
892
|
const bodyShape = bodySchemaShape(
|
|
839
893
|
parameterDefinitions.find((p) => p.type === "Body")?.schema
|
|
840
894
|
);
|
|
@@ -1184,7 +1238,8 @@ async function executeGraphTool(tool, config, graphClient, params, authManager)
|
|
|
1184
1238
|
duration_ms: Date.now() - startTime,
|
|
1185
1239
|
...targetResource ? { target_resource: targetResource } : {},
|
|
1186
1240
|
error_type: err?.name || "Error",
|
|
1187
|
-
...thrownErrorAuditFields(error)
|
|
1241
|
+
...thrownErrorAuditFields(error),
|
|
1242
|
+
...recipientAuditFields(body)
|
|
1188
1243
|
});
|
|
1189
1244
|
return {
|
|
1190
1245
|
content: [
|
|
@@ -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
|
+
};
|
package/docs/deployment.md
CHANGED
|
@@ -213,7 +213,7 @@ The client automatically discovers OAuth endpoints and opens a browser for authe
|
|
|
213
213
|
- **Tool filtering**: use `--enabled-tools <regex>` or `--preset <names>` to restrict available tools
|
|
214
214
|
- **CORS**: configure `MS365_MCP_CORS_ORIGIN` to restrict allowed origins (defaults to `http://localhost:3000`); set explicitly when clients run on a different origin
|
|
215
215
|
- **Disable Dynamic Client Registration**: when only a known client talks to the server, set `MS365_MCP_DISABLE_DCR=true` (or pass `--no-dynamic-registration`) to close the anonymous `/register` endpoint
|
|
216
|
-
- **Structured audit log**: enabled by default. Every tool invocation emits one JSON line on stderr (captured by the container platform's log collector) and to `~/.ms-365-mcp-server/logs/audit.log` (mode `0o600`) with `{ event, request_id, user_principal_name, tool, http_method, status, duration_ms, target_resource?, error_type?, error_code? }`. Policy-blocked tool attempts emit `event: "tool.denied"` with `status: "denied"`, `reason` (`allowed_scopes` or `tool_allowlist`), and `missing_scopes` when applicable. When an audited generated Microsoft Graph tool targets a derivable resource through an ID-like path parameter such as `{message-id}` or `{driveItem-id}`, `target_resource` is `{ type, id }`, where `id` is the Graph path up to that resource ID. Later path parameters such as `{path}`, query values,
|
|
216
|
+
- **Structured audit log**: enabled by default. Every tool invocation that reaches Microsoft Graph emits one JSON line on stderr (captured by the container platform's log collector) and to `~/.ms-365-mcp-server/logs/audit.log` (mode `0o600`, or under `MS365_MCP_LOG_DIR` when set) with `{ event, request_id, user_principal_name, tool, http_method, http_status?, status, duration_ms, recipient_count?, recipient_domains?, recipient_domains_truncated?, graph_batch_subrequest_count?, graph_batch_http_status_counts?, graph_batch_error_code_counts?, target_resource?, error_type?, error_code? }`. A few refusals short-circuit before that and emit nothing: a confirm-gate rejection, an `account` param that contradicts the bearer identity, and a failure to resolve an account token. Policy-blocked tool attempts emit `event: "tool.denied"` with `status: "denied"`, `reason` (`allowed_scopes` or `tool_allowlist`), and `missing_scopes` when applicable. When an audited generated Microsoft Graph tool targets a derivable resource through an ID-like path parameter such as `{message-id}` or `{driveItem-id}`, `target_resource` is `{ type, id }`, where `id` is the Graph path up to that resource ID. Later path parameters such as `{path}`, query values, returned content, and Graph response bodies are NEVER recorded, and error messages are reduced to `error_type` / `error_code` so upstream library errors do not leak token fragments or query-string PII. Two things derived from tool parameters **are** recorded, both deliberately. First, `target_resource.id` substitutes ID-like path parameters into the resource path, so a tool on `/users/{user-id}/...` records whatever identifier the caller passed, which may be a full email address. Second, a request whose body carries `toRecipients` / `ccRecipients` / `bccRecipients` / `attendees` / `recipients`, at any casing and several levels down, including inside a `graph-batch` sub-request, records `recipient_count`, the number of entries in those arrays, and `recipient_domains`, the **domain part only** of their addresses and only where it parses as a plain hostname, never the local part and never a subject or message body. An entry that names someone without an address (a `driveRecipient` given as `alias` or `objectId`) counts but contributes no domain. It keys on body shape rather than on the endpoint, so it covers sends, forwards, invites and file shares but equally draft creation and edits, event updates and `findMeetingTimes`, and it reads high rather than low: an attached message's own recipients are counted too. `recipient_domains` holds at most 50 **distinct domains**, alphabetically, and sets `recipient_domains_truncated: true` when there were more; `recipient_count` is unaffected by the cap. All three are absent when the body carries no recipient array at all. A request that fails after reaching Graph still records recipients, since a timeout is not proof of non-delivery. Gaps remain, so absence proves nothing: a draft composed outside this server and sent by id, the original thread's recipients on a reply (Graph resolves those server-side), and a body nesting recipients deeper than the walker descends. This describes the structured audit log only; the operational logger is separate and does log tool parameters. Forms the "who accessed what, when" trail required for GDPR / HIPAA / PIPEDA / SOC 2 audit. Opt-out: `MS365_MCP_AUDIT_LOG=false`
|
|
217
217
|
- **Graph resilience**: every call to Microsoft Graph is wrapped with a fetch timeout (default 100 s via `MS365_MCP_GRAPH_TIMEOUT_MS`), retry-with-backoff on 429 / 503 / 504 / network errors (default 3 retries, full-jitter exponential backoff, honours `Retry-After`; 503 / 504 / network errors only retried for idempotent methods, 429 retried on all methods), and a process-wide circuit breaker that opens after 5 consecutive failures and cools down for 30 s (`MS365_MCP_GRAPH_CIRCUIT_THRESHOLD` / `MS365_MCP_GRAPH_CIRCUIT_COOLDOWN_MS`). Disable the breaker for trusted automation: `MS365_MCP_GRAPH_CIRCUIT_DISABLED=true`
|
|
218
218
|
- **Confirm gate on destructive tools**: opt-in, **off by default**. Enable with `MS365_MCP_REQUIRE_CONFIRM=true`. When on, destructive tools (POST except `readOnly`, PATCH, PUT, DELETE — `delete-mail-message`, `send-mail`, `update-event`, etc.) return `{ "error": "confirmation_required" }` until the caller re-invokes them with `"confirm": true`. Mitigates accidental writes when an LLM misroutes a request or follows an injected instruction. Shipped opt-in so it is a non-breaking, additive layer that can coexist with client-side elicitation prompts (MCP Elicitation API) where the client supports them.
|
|
219
219
|
|
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.146.
|
|
4
|
+
"version": "0.146.2",
|
|
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",
|