@littlebearapps/outlook-assistant 3.12.1 → 3.14.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/.env.example +27 -3
- package/README.md +108 -33
- package/advanced/index.js +44 -174
- package/auth/auth-errors.js +23 -1
- package/auth/client-config.js +142 -0
- package/auth/index.js +4 -2
- package/auth/oauth-server.js +12 -2
- package/auth/token-manager.js +7 -3
- package/auth/token-storage.js +46 -33
- package/auth/tools.js +223 -93
- package/calendar/attendees.js +36 -0
- package/calendar/cancel.js +9 -25
- package/calendar/create.js +42 -48
- package/calendar/decline.js +10 -25
- package/calendar/delete.js +10 -25
- package/calendar/index.js +20 -37
- package/calendar/list.js +4 -16
- package/calendar/preview.js +335 -0
- package/calendar/update.js +42 -86
- package/categories/index.js +59 -264
- package/config.js +36 -2
- package/contacts/index.js +72 -128
- package/email/attachments.js +42 -124
- package/email/conversations.js +44 -78
- package/email/delta.js +10 -34
- package/email/draft.js +140 -96
- package/email/export.js +141 -110
- package/email/folder-utils.js +3 -2
- package/email/headers.js +11 -49
- package/email/index.js +85 -109
- package/email/list.js +4 -17
- package/email/mail-tips.js +86 -57
- package/email/mark-as-read.js +13 -49
- package/email/mime.js +14 -49
- package/email/read.js +16 -50
- package/email/search.js +46 -86
- package/email/send.js +82 -48
- package/folder/create.js +6 -25
- package/folder/delete.js +117 -38
- package/folder/index.js +17 -16
- package/folder/list.js +5 -17
- package/folder/move.js +13 -42
- package/folder/resolve.js +11 -6
- package/folder/stats.js +6 -20
- package/index.js +23 -45
- package/llms-install.md +31 -7
- package/llms.txt +19 -10
- package/outlook-auth-server.js +10 -3
- package/package.json +6 -2
- package/request-handler.js +217 -116
- package/rules/create.js +27 -70
- package/rules/index.js +30 -92
- package/rules/list.js +5 -17
- package/rules/rule-builder.js +57 -20
- package/rules/update.js +26 -60
- package/server.js +37 -0
- package/settings/index.js +142 -143
- package/tools.js +30 -0
- package/utils/field-presets.js +4 -2
- package/utils/graph-api.js +65 -22
- package/utils/logger.js +251 -0
- package/utils/mock-data.js +91 -2
- package/utils/read-only.js +59 -0
- package/utils/response-formatter.js +54 -15
- package/utils/risk-classes.js +324 -0
- package/utils/safe-write.js +372 -6
- package/utils/safety.js +109 -25
- package/utils/server-instructions.js +62 -0
- package/utils/tool-error.js +33 -0
package/advanced/index.js
CHANGED
|
@@ -26,6 +26,8 @@ const {
|
|
|
26
26
|
zonedParts,
|
|
27
27
|
zonedWallTimeToUtcMs,
|
|
28
28
|
} = require('../utils/datetime');
|
|
29
|
+
const { toolMetadata } = require('../utils/risk-classes');
|
|
30
|
+
const { toolError, authRequiredError } = require('../utils/tool-error');
|
|
29
31
|
|
|
30
32
|
/**
|
|
31
33
|
* Format an email for display (simplified)
|
|
@@ -78,14 +80,9 @@ async function handleAccessSharedMailbox(args) {
|
|
|
78
80
|
const sharedMailbox = args.sharedMailbox || args.email;
|
|
79
81
|
|
|
80
82
|
if (!sharedMailbox) {
|
|
81
|
-
return
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
type: 'text',
|
|
85
|
-
text: "Shared mailbox email address is required (e.g., 'shared@company.com').",
|
|
86
|
-
},
|
|
87
|
-
],
|
|
88
|
-
};
|
|
83
|
+
return toolError(
|
|
84
|
+
"Shared mailbox email address is required (e.g., 'shared@company.com')."
|
|
85
|
+
);
|
|
89
86
|
}
|
|
90
87
|
|
|
91
88
|
// Validate up front, through the same helper every other tool uses. The
|
|
@@ -96,17 +93,12 @@ async function handleAccessSharedMailbox(args) {
|
|
|
96
93
|
try {
|
|
97
94
|
mailboxPrefix = validateMailboxPrefix(sharedMailbox);
|
|
98
95
|
} catch (error) {
|
|
99
|
-
return
|
|
96
|
+
return toolError(error.message);
|
|
100
97
|
}
|
|
101
98
|
if (mailboxPrefix === 'me') {
|
|
102
|
-
return
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
type: 'text',
|
|
106
|
-
text: 'access-shared-mailbox reads another mailbox — pass its email address. To read your own mailbox, use `search-emails`.',
|
|
107
|
-
},
|
|
108
|
-
],
|
|
109
|
-
};
|
|
99
|
+
return toolError(
|
|
100
|
+
'access-shared-mailbox reads another mailbox — pass its email address. To read your own mailbox, use `search-emails`.'
|
|
101
|
+
);
|
|
110
102
|
}
|
|
111
103
|
|
|
112
104
|
// listFolders mode: enumerate the shared mailbox's folder tree so callers
|
|
@@ -115,9 +107,7 @@ async function handleAccessSharedMailbox(args) {
|
|
|
115
107
|
|
|
116
108
|
if (listFolders) {
|
|
117
109
|
if (!sharedEnabled) {
|
|
118
|
-
return
|
|
119
|
-
content: [{ type: 'text', text: SHARED_MAILBOX_DISABLED_MESSAGE }],
|
|
120
|
-
};
|
|
110
|
+
return toolError(SHARED_MAILBOX_DISABLED_MESSAGE);
|
|
121
111
|
}
|
|
122
112
|
return handleListSharedMailboxFolders(sharedMailbox, args);
|
|
123
113
|
}
|
|
@@ -151,18 +141,12 @@ async function handleAccessSharedMailbox(args) {
|
|
|
151
141
|
if (!/not found|ambiguous/i.test(resolveError.message)) {
|
|
152
142
|
throw resolveError;
|
|
153
143
|
}
|
|
154
|
-
return
|
|
155
|
-
|
|
156
|
-
{
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
`Searched in ${sharedMailbox}. List its folders first to get exact names/IDs:\n` +
|
|
161
|
-
'- `access-shared-mailbox` with `listFolders: true`, or\n' +
|
|
162
|
-
`- \`folders\` tool with \`action: list\`, \`sharedMailbox: "${sharedMailbox}"\``,
|
|
163
|
-
},
|
|
164
|
-
],
|
|
165
|
-
};
|
|
144
|
+
return toolError(
|
|
145
|
+
`${resolveError.message}\n\n` +
|
|
146
|
+
`Searched in ${sharedMailbox}. List its folders first to get exact names/IDs:\n` +
|
|
147
|
+
'- `access-shared-mailbox` with `listFolders: true`, or\n' +
|
|
148
|
+
`- \`folders\` tool with \`action: list\`, \`sharedMailbox: "${sharedMailbox}"\``
|
|
149
|
+
);
|
|
166
150
|
}
|
|
167
151
|
}
|
|
168
152
|
|
|
@@ -244,49 +228,25 @@ async function handleAccessSharedMailbox(args) {
|
|
|
244
228
|
};
|
|
245
229
|
} catch (error) {
|
|
246
230
|
if (error.message === 'Authentication required') {
|
|
247
|
-
return
|
|
248
|
-
content: [
|
|
249
|
-
{
|
|
250
|
-
type: 'text',
|
|
251
|
-
text: "Authentication required. Please use the 'auth' tool with action=authenticate first.",
|
|
252
|
-
},
|
|
253
|
-
],
|
|
254
|
-
};
|
|
231
|
+
return authRequiredError();
|
|
255
232
|
}
|
|
256
233
|
|
|
257
234
|
if (
|
|
258
235
|
error.message.includes('Access is denied') ||
|
|
259
236
|
error.message.includes('403')
|
|
260
237
|
) {
|
|
261
|
-
return
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
type: 'text',
|
|
265
|
-
text: `Access denied to shared mailbox "${sharedMailbox}".\n\n**Possible causes:**\n- You don't have access to this shared mailbox\n- The Mail.Read.Shared permission is not granted\n- The shared mailbox address is incorrect${sharedEnabled ? '' : ENABLE_SHARED_HINT}`,
|
|
266
|
-
},
|
|
267
|
-
],
|
|
268
|
-
};
|
|
238
|
+
return toolError(
|
|
239
|
+
`Access denied to shared mailbox "${sharedMailbox}".\n\n**Possible causes:**\n- You don't have access to this shared mailbox\n- The Mail.Read.Shared permission is not granted\n- The shared mailbox address is incorrect${sharedEnabled ? '' : ENABLE_SHARED_HINT}`
|
|
240
|
+
);
|
|
269
241
|
}
|
|
270
242
|
|
|
271
243
|
if (error.message.includes('not found') || error.message.includes('404')) {
|
|
272
|
-
return
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
type: 'text',
|
|
276
|
-
text: `Shared mailbox "${sharedMailbox}" not found. Please verify the email address.`,
|
|
277
|
-
},
|
|
278
|
-
],
|
|
279
|
-
};
|
|
244
|
+
return toolError(
|
|
245
|
+
`Shared mailbox "${sharedMailbox}" not found. Please verify the email address.`
|
|
246
|
+
);
|
|
280
247
|
}
|
|
281
248
|
|
|
282
|
-
return {
|
|
283
|
-
content: [
|
|
284
|
-
{
|
|
285
|
-
type: 'text',
|
|
286
|
-
text: `Error accessing shared mailbox: ${error.message}`,
|
|
287
|
-
},
|
|
288
|
-
],
|
|
289
|
-
};
|
|
249
|
+
return toolError(`Error accessing shared mailbox: ${error.message}`);
|
|
290
250
|
}
|
|
291
251
|
}
|
|
292
252
|
|
|
@@ -379,38 +339,19 @@ async function handleListSharedMailboxFolders(sharedMailbox, args) {
|
|
|
379
339
|
};
|
|
380
340
|
} catch (error) {
|
|
381
341
|
if (error.message === 'Authentication required') {
|
|
382
|
-
return
|
|
383
|
-
content: [
|
|
384
|
-
{
|
|
385
|
-
type: 'text',
|
|
386
|
-
text: "Authentication required. Please use the 'auth' tool with action=authenticate first.",
|
|
387
|
-
},
|
|
388
|
-
],
|
|
389
|
-
};
|
|
342
|
+
return authRequiredError();
|
|
390
343
|
}
|
|
391
344
|
|
|
392
345
|
if (
|
|
393
346
|
error.message.includes('Access is denied') ||
|
|
394
347
|
error.message.includes('403')
|
|
395
348
|
) {
|
|
396
|
-
return
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
type: 'text',
|
|
400
|
-
text: `Access denied to shared mailbox "${sharedMailbox}".\n\n**Possible causes:**\n- You don't have delegate access to this shared mailbox\n- The Mail.Read.Shared permission is not granted\n- The shared mailbox address is incorrect`,
|
|
401
|
-
},
|
|
402
|
-
],
|
|
403
|
-
};
|
|
349
|
+
return toolError(
|
|
350
|
+
`Access denied to shared mailbox "${sharedMailbox}".\n\n**Possible causes:**\n- You don't have delegate access to this shared mailbox\n- The Mail.Read.Shared permission is not granted\n- The shared mailbox address is incorrect`
|
|
351
|
+
);
|
|
404
352
|
}
|
|
405
353
|
|
|
406
|
-
return {
|
|
407
|
-
content: [
|
|
408
|
-
{
|
|
409
|
-
type: 'text',
|
|
410
|
-
text: `Error listing shared mailbox folders: ${error.message}`,
|
|
411
|
-
},
|
|
412
|
-
],
|
|
413
|
-
};
|
|
354
|
+
return toolError(`Error listing shared mailbox folders: ${error.message}`);
|
|
414
355
|
}
|
|
415
356
|
}
|
|
416
357
|
|
|
@@ -474,14 +415,7 @@ async function handleSetMessageFlag(args) {
|
|
|
474
415
|
const ids = messageIds || (messageId ? [messageId] : []);
|
|
475
416
|
|
|
476
417
|
if (ids.length === 0) {
|
|
477
|
-
return
|
|
478
|
-
content: [
|
|
479
|
-
{
|
|
480
|
-
type: 'text',
|
|
481
|
-
text: 'Message ID (messageId) or IDs (messageIds) required.',
|
|
482
|
-
},
|
|
483
|
-
],
|
|
484
|
-
};
|
|
418
|
+
return toolError('Message ID (messageId) or IDs (messageIds) required.');
|
|
485
419
|
}
|
|
486
420
|
|
|
487
421
|
// Build flag object. Zoned values (Z/offset) are sent as the same instant in
|
|
@@ -568,23 +502,9 @@ async function handleSetMessageFlag(args) {
|
|
|
568
502
|
};
|
|
569
503
|
} catch (error) {
|
|
570
504
|
if (error.message === 'Authentication required') {
|
|
571
|
-
return
|
|
572
|
-
content: [
|
|
573
|
-
{
|
|
574
|
-
type: 'text',
|
|
575
|
-
text: "Authentication required. Please use the 'auth' tool with action=authenticate first.",
|
|
576
|
-
},
|
|
577
|
-
],
|
|
578
|
-
};
|
|
505
|
+
return authRequiredError();
|
|
579
506
|
}
|
|
580
|
-
return {
|
|
581
|
-
content: [
|
|
582
|
-
{
|
|
583
|
-
type: 'text',
|
|
584
|
-
text: `Error setting message flag: ${error.message}`,
|
|
585
|
-
},
|
|
586
|
-
],
|
|
587
|
-
};
|
|
507
|
+
return toolError(`Error setting message flag: ${error.message}`);
|
|
588
508
|
}
|
|
589
509
|
}
|
|
590
510
|
|
|
@@ -599,14 +519,7 @@ async function handleClearMessageFlag(args) {
|
|
|
599
519
|
const ids = messageIds || (messageId ? [messageId] : []);
|
|
600
520
|
|
|
601
521
|
if (ids.length === 0) {
|
|
602
|
-
return
|
|
603
|
-
content: [
|
|
604
|
-
{
|
|
605
|
-
type: 'text',
|
|
606
|
-
text: 'Message ID (messageId) or IDs (messageIds) required.',
|
|
607
|
-
},
|
|
608
|
-
],
|
|
609
|
-
};
|
|
522
|
+
return toolError('Message ID (messageId) or IDs (messageIds) required.');
|
|
610
523
|
}
|
|
611
524
|
|
|
612
525
|
try {
|
|
@@ -671,23 +584,9 @@ async function handleClearMessageFlag(args) {
|
|
|
671
584
|
};
|
|
672
585
|
} catch (error) {
|
|
673
586
|
if (error.message === 'Authentication required') {
|
|
674
|
-
return
|
|
675
|
-
content: [
|
|
676
|
-
{
|
|
677
|
-
type: 'text',
|
|
678
|
-
text: "Authentication required. Please use the 'auth' tool with action=authenticate first.",
|
|
679
|
-
},
|
|
680
|
-
],
|
|
681
|
-
};
|
|
587
|
+
return authRequiredError();
|
|
682
588
|
}
|
|
683
|
-
return {
|
|
684
|
-
content: [
|
|
685
|
-
{
|
|
686
|
-
type: 'text',
|
|
687
|
-
text: `Error clearing message flag: ${error.message}`,
|
|
688
|
-
},
|
|
689
|
-
],
|
|
690
|
-
};
|
|
589
|
+
return toolError(`Error clearing message flag: ${error.message}`);
|
|
691
590
|
}
|
|
692
591
|
}
|
|
693
592
|
|
|
@@ -735,14 +634,9 @@ async function handleFindMeetingRooms(args) {
|
|
|
735
634
|
const explanation = isLikelyPersonal
|
|
736
635
|
? 'Meeting room search is M365-only. Personal Outlook.com accounts cannot use this feature — there are no rooms to find. Connect a Microsoft 365 work/school account to enable.'
|
|
737
636
|
: 'This feature requires:\n- Places.Read.All permission\n- Meeting rooms configured in your organization';
|
|
738
|
-
return
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
type: 'text',
|
|
742
|
-
text: `Unable to find meeting rooms.\n\n**Note**: ${explanation}\n\nError: ${errMsg}`,
|
|
743
|
-
},
|
|
744
|
-
],
|
|
745
|
-
};
|
|
637
|
+
return toolError(
|
|
638
|
+
`Unable to find meeting rooms.\n\n**Note**: ${explanation}\n\nError: ${errMsg}`
|
|
639
|
+
);
|
|
746
640
|
}
|
|
747
641
|
}
|
|
748
642
|
|
|
@@ -843,23 +737,9 @@ async function handleFindMeetingRooms(args) {
|
|
|
843
737
|
};
|
|
844
738
|
} catch (error) {
|
|
845
739
|
if (error.message === 'Authentication required') {
|
|
846
|
-
return
|
|
847
|
-
content: [
|
|
848
|
-
{
|
|
849
|
-
type: 'text',
|
|
850
|
-
text: "Authentication required. Please use the 'auth' tool with action=authenticate first.",
|
|
851
|
-
},
|
|
852
|
-
],
|
|
853
|
-
};
|
|
740
|
+
return authRequiredError();
|
|
854
741
|
}
|
|
855
|
-
return {
|
|
856
|
-
content: [
|
|
857
|
-
{
|
|
858
|
-
type: 'text',
|
|
859
|
-
text: `Error finding meeting rooms: ${error.message}`,
|
|
860
|
-
},
|
|
861
|
-
],
|
|
862
|
-
};
|
|
742
|
+
return toolError(`Error finding meeting rooms: ${error.message}`);
|
|
863
743
|
}
|
|
864
744
|
}
|
|
865
745
|
|
|
@@ -868,14 +748,8 @@ const advancedTools = [
|
|
|
868
748
|
{
|
|
869
749
|
name: 'access-shared-mailbox',
|
|
870
750
|
description:
|
|
871
|
-
"List emails
|
|
872
|
-
|
|
873
|
-
title: 'Shared Mailbox',
|
|
874
|
-
readOnlyHint: true,
|
|
875
|
-
// openWorldHint: returns shared-mailbox messages authored by external
|
|
876
|
-
// senders. (#92)
|
|
877
|
-
openWorldHint: true,
|
|
878
|
-
},
|
|
751
|
+
"List emails or folders in a shared mailbox the signed-in user can access (read-only). Returns messages from `sharedMailbox` (alias `email`) and `folder` (default `inbox`) with id/subject/from/receivedDateTime/preview, the same shape as `search-emails` list mode. `folder` takes a well-known name (inbox, sent, archive…), a custom/localized display name (e.g. `Archiv`), a nested path (e.g. `Inbox/Vendors/Acme`), or pass a raw `folderId`. `listFolders: true` enumerates the shared mailbox's folder tree (names, paths, IDs, counts), to find custom subfolders before reading them. Needs the mailbox delegated to the signed-in user in Exchange (admin-configured). `outputVerbosity` sets field count and `count` (default 25, max 50) page size. For search and filters over a shared mailbox, use `search-emails` with `sharedMailbox` set. Custom/localized names, nested paths and `listFolders` need the server opt-in setting OUTLOOK_SHARED_MAILBOX (work/school only); without it `folder` must be a well-known name or a folder ID.",
|
|
752
|
+
...toolMetadata('access-shared-mailbox', 'Shared Mailbox'),
|
|
879
753
|
inputSchema: {
|
|
880
754
|
type: 'object',
|
|
881
755
|
properties: {
|
|
@@ -901,7 +775,7 @@ const advancedTools = [
|
|
|
901
775
|
listFolders: {
|
|
902
776
|
type: 'boolean',
|
|
903
777
|
description:
|
|
904
|
-
"Enumerate the shared mailbox's full folder tree (names, paths, IDs, item counts)
|
|
778
|
+
"Enumerate the shared mailbox's full folder tree (names, paths, IDs, item counts) in place of reading messages.",
|
|
905
779
|
},
|
|
906
780
|
count: {
|
|
907
781
|
type: 'number',
|
|
@@ -922,11 +796,7 @@ const advancedTools = [
|
|
|
922
796
|
name: 'find-meeting-rooms',
|
|
923
797
|
description:
|
|
924
798
|
"Discover bookable meeting rooms in the user's organisation via the Graph rooms endpoint (read-only). Returns room resources with displayName, emailAddress, building, floor, capacity, and bookingType — suitable for piping into `create-event` as attendees. Filter by `query` (matches name/email), `building`, `floor`, or minimum `capacity`. Returns empty list on personal accounts (the rooms endpoint is M365-only). Use `outputVerbosity` to control field count.",
|
|
925
|
-
|
|
926
|
-
title: 'Meeting Rooms',
|
|
927
|
-
readOnlyHint: true,
|
|
928
|
-
openWorldHint: false,
|
|
929
|
-
},
|
|
799
|
+
...toolMetadata('find-meeting-rooms', 'Meeting Rooms'),
|
|
930
800
|
inputSchema: {
|
|
931
801
|
type: 'object',
|
|
932
802
|
properties: {
|
package/auth/auth-errors.js
CHANGED
|
@@ -86,4 +86,26 @@ function describeAuthError(input) {
|
|
|
86
86
|
);
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
-
|
|
89
|
+
/**
|
|
90
|
+
* Short, PII-free label for the default log level (#278): `label` plus the
|
|
91
|
+
* first AADSTS code (or a network error code) found, never the description,
|
|
92
|
+
* which can carry the user's address.
|
|
93
|
+
* @param {string} label - e.g. 'refresh-failed'
|
|
94
|
+
* @param {Error|string|null|undefined} input
|
|
95
|
+
* @returns {string} - e.g. 'refresh-failed:AADSTS70008'
|
|
96
|
+
*/
|
|
97
|
+
function authErrorLogLabel(label, input) {
|
|
98
|
+
const aadsts = toMessage(input).match(/AADSTS\d+/);
|
|
99
|
+
if (aadsts) return `${label}:${aadsts[0]}`;
|
|
100
|
+
const code = input && input.code;
|
|
101
|
+
return typeof code === 'string' && /^[A-Z][A-Z_]{1,30}$/.test(code)
|
|
102
|
+
? `${label}:${code}`
|
|
103
|
+
: label;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
module.exports = {
|
|
107
|
+
getAuthErrorHints,
|
|
108
|
+
describeAuthError,
|
|
109
|
+
authErrorLogLabel,
|
|
110
|
+
AUTH_ERROR_HINTS,
|
|
111
|
+
};
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime-supplied Azure Application (client) ID.
|
|
3
|
+
*
|
|
4
|
+
* Plugin marketplaces (GitHub Copilot, Cursor — Agent Plugins 1.0) ship a
|
|
5
|
+
* static `mcp.json` with no way to prompt for settings, so users there can't
|
|
6
|
+
* set OUTLOOK_CLIENT_ID. Device-code sign-in only needs the client ID (never
|
|
7
|
+
* the secret), so the `auth` tool accepts it at runtime and it's persisted to
|
|
8
|
+
* `~/.outlook-assistant-config.json`.
|
|
9
|
+
*
|
|
10
|
+
* Precedence: OUTLOOK_CLIENT_ID env → MS_CLIENT_ID env (legacy) → saved file.
|
|
11
|
+
*
|
|
12
|
+
* Deliberately does NOT require ../config (config.js requires this module).
|
|
13
|
+
*/
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const os = require('os');
|
|
16
|
+
const path = require('path');
|
|
17
|
+
|
|
18
|
+
const CONFIG_FILE_NAME = '.outlook-assistant-config.json';
|
|
19
|
+
|
|
20
|
+
// Azure Application (client) IDs are GUIDs.
|
|
21
|
+
const CLIENT_ID_RE =
|
|
22
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Path of the persisted config file. Resolved per call (not at load) so a
|
|
26
|
+
* changed HOME — e.g. in tests — is honoured.
|
|
27
|
+
* @returns {string}
|
|
28
|
+
*/
|
|
29
|
+
function getConfigPath() {
|
|
30
|
+
const homeDir = process.env.HOME || process.env.USERPROFILE || os.homedir();
|
|
31
|
+
return path.join(homeDir, CONFIG_FILE_NAME);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {unknown} id
|
|
36
|
+
* @returns {boolean} - true when `id` (trimmed) is a GUID
|
|
37
|
+
*/
|
|
38
|
+
function isValidClientId(id) {
|
|
39
|
+
return typeof id === 'string' && CLIENT_ID_RE.test(id.trim());
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Read the whole config file. Never throws.
|
|
44
|
+
* @returns {object} - Parsed object, or {} when missing/unreadable/not an object
|
|
45
|
+
*/
|
|
46
|
+
function readConfigFile() {
|
|
47
|
+
try {
|
|
48
|
+
const parsed = JSON.parse(fs.readFileSync(getConfigPath(), 'utf8'));
|
|
49
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
50
|
+
? parsed
|
|
51
|
+
: {};
|
|
52
|
+
} catch {
|
|
53
|
+
return {};
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The saved client ID, or '' when there is none or it isn't a valid GUID.
|
|
59
|
+
* Never throws.
|
|
60
|
+
* @returns {string}
|
|
61
|
+
*/
|
|
62
|
+
function loadSavedClientId() {
|
|
63
|
+
const { clientId } = readConfigFile();
|
|
64
|
+
return isValidClientId(clientId) ? clientId.trim() : '';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Validate and persist a client ID (mode 0600, temp file + rename), keeping
|
|
69
|
+
* any other keys already in the file.
|
|
70
|
+
* @param {string} id
|
|
71
|
+
* @returns {string} - The normalised (trimmed) ID that was saved
|
|
72
|
+
* @throws {Error} - When `id` isn't a GUID or the write fails
|
|
73
|
+
*/
|
|
74
|
+
function saveClientId(id) {
|
|
75
|
+
if (!isValidClientId(id)) {
|
|
76
|
+
throw new Error(
|
|
77
|
+
'Invalid client ID: expected the Application (client) ID GUID from your Azure app registration.'
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
const clientId = id.trim();
|
|
81
|
+
const filePath = getConfigPath();
|
|
82
|
+
const data = { ...readConfigFile(), clientId };
|
|
83
|
+
const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
84
|
+
try {
|
|
85
|
+
fs.writeFileSync(tmpPath, `${JSON.stringify(data, null, 2)}\n`, {
|
|
86
|
+
mode: 0o600,
|
|
87
|
+
flag: 'wx',
|
|
88
|
+
});
|
|
89
|
+
fs.renameSync(tmpPath, filePath);
|
|
90
|
+
} catch (error) {
|
|
91
|
+
try {
|
|
92
|
+
fs.unlinkSync(tmpPath);
|
|
93
|
+
} catch {
|
|
94
|
+
// Temp file was never created or is already gone
|
|
95
|
+
}
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
return clientId;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Which environment variable supplies the client ID, if any.
|
|
103
|
+
* @returns {{name: string, value: string}|null}
|
|
104
|
+
*/
|
|
105
|
+
function getEnvClientId() {
|
|
106
|
+
// Trimmed, and blank counts as unset: plugin managers pass "" (or stray
|
|
107
|
+
// whitespace) for an unset option, which must not hide a saved ID.
|
|
108
|
+
for (const name of ['OUTLOOK_CLIENT_ID', 'MS_CLIENT_ID']) {
|
|
109
|
+
const value = (process.env[name] || '').trim();
|
|
110
|
+
if (value) return { name, value };
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Effective client ID: env (OUTLOOK_CLIENT_ID, then legacy MS_CLIENT_ID),
|
|
117
|
+
* then the saved file, then ''.
|
|
118
|
+
* @returns {string}
|
|
119
|
+
*/
|
|
120
|
+
function resolveClientId() {
|
|
121
|
+
const env = getEnvClientId();
|
|
122
|
+
return env ? env.value : loadSavedClientId();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* @returns {'env'|'saved'|'none'} - Where the effective client ID comes from
|
|
127
|
+
*/
|
|
128
|
+
function getClientIdSource() {
|
|
129
|
+
if (getEnvClientId()) return 'env';
|
|
130
|
+
return loadSavedClientId() ? 'saved' : 'none';
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
module.exports = {
|
|
134
|
+
CONFIG_FILE_NAME,
|
|
135
|
+
getConfigPath,
|
|
136
|
+
isValidClientId,
|
|
137
|
+
loadSavedClientId,
|
|
138
|
+
saveClientId,
|
|
139
|
+
getEnvClientId,
|
|
140
|
+
resolveClientId,
|
|
141
|
+
getClientIdSource,
|
|
142
|
+
};
|
package/auth/index.js
CHANGED
|
@@ -6,9 +6,11 @@ const TokenStorage = require('./token-storage');
|
|
|
6
6
|
const config = require('../config');
|
|
7
7
|
const { authTools, setToolCount } = require('./tools');
|
|
8
8
|
|
|
9
|
-
// Singleton TokenStorage instance with auto-refresh support
|
|
9
|
+
// Singleton TokenStorage instance with auto-refresh support. No clientId is
|
|
10
|
+
// passed: TokenStorage resolves it on each use (env → saved config file), so
|
|
11
|
+
// an ID saved at runtime via `auth action=authenticate clientId=…` applies
|
|
12
|
+
// without a restart.
|
|
10
13
|
const tokenStorage = new TokenStorage({
|
|
11
|
-
clientId: config.AUTH_CONFIG.clientId,
|
|
12
14
|
clientSecret: config.AUTH_CONFIG.clientSecret,
|
|
13
15
|
tokenStorePath: config.AUTH_CONFIG.tokenStorePath,
|
|
14
16
|
scopes: config.AUTH_CONFIG.scopes,
|
package/auth/oauth-server.js
CHANGED
|
@@ -4,6 +4,9 @@ const _https = require('https'); // Reserved for future HTTPS support
|
|
|
4
4
|
const _fs = require('fs'); // Reserved for future HTTPS support
|
|
5
5
|
const crypto = require('crypto'); // Added for generating random string
|
|
6
6
|
const TokenStorage = require('./token-storage'); // Assuming TokenStorage is in the same directory
|
|
7
|
+
const { loadSavedClientId } = require('./client-config');
|
|
8
|
+
const { authErrorLogLabel } = require('./auth-errors');
|
|
9
|
+
const { log } = require('../utils/logger');
|
|
7
10
|
|
|
8
11
|
// HTML templates
|
|
9
12
|
function escapeHtml(unsafe) {
|
|
@@ -54,8 +57,11 @@ const templates = {
|
|
|
54
57
|
|
|
55
58
|
function createAuthConfig(envPrefix = 'OUTLOOK_') {
|
|
56
59
|
return {
|
|
60
|
+
// Env first; then the ID saved via `auth action=authenticate clientId=…`.
|
|
57
61
|
clientId:
|
|
58
|
-
process.env[`${envPrefix}CLIENT_ID`] ||
|
|
62
|
+
process.env[`${envPrefix}CLIENT_ID`] ||
|
|
63
|
+
process.env.MS_CLIENT_ID ||
|
|
64
|
+
loadSavedClientId(),
|
|
59
65
|
clientSecret:
|
|
60
66
|
process.env[`${envPrefix}CLIENT_SECRET`] ||
|
|
61
67
|
process.env.MS_CLIENT_SECRET ||
|
|
@@ -193,7 +199,11 @@ function setupOAuthRoutes(
|
|
|
193
199
|
await tokenStorage.exchangeCodeForTokens(code);
|
|
194
200
|
res.send(templates.authSuccess);
|
|
195
201
|
} catch (exchangeError) {
|
|
196
|
-
|
|
202
|
+
log.note(
|
|
203
|
+
'auth',
|
|
204
|
+
authErrorLogLabel('token-exchange-failed', exchangeError)
|
|
205
|
+
);
|
|
206
|
+
log.debug('Token exchange error:', exchangeError);
|
|
197
207
|
res.status(500).send(templates.tokenExchangeError(exchangeError));
|
|
198
208
|
}
|
|
199
209
|
});
|
package/auth/token-manager.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
const fs = require('fs');
|
|
5
5
|
const config = require('../config');
|
|
6
|
+
const { log } = require('../utils/logger');
|
|
6
7
|
|
|
7
8
|
// Global variable to store tokens
|
|
8
9
|
let cachedTokens = null;
|
|
@@ -38,11 +39,13 @@ function loadTokenCache() {
|
|
|
38
39
|
cachedTokens = tokens;
|
|
39
40
|
return tokens;
|
|
40
41
|
} catch (parseError) {
|
|
41
|
-
|
|
42
|
+
log.note('auth', 'token-cache-unreadable');
|
|
43
|
+
log.debug('Error parsing token file:', parseError.message);
|
|
42
44
|
return null;
|
|
43
45
|
}
|
|
44
46
|
} catch (error) {
|
|
45
|
-
|
|
47
|
+
log.note('auth', 'token-cache-unreadable');
|
|
48
|
+
log.debug('Error loading token cache:', error.message);
|
|
46
49
|
return null;
|
|
47
50
|
}
|
|
48
51
|
}
|
|
@@ -64,7 +67,8 @@ function saveTokenCache(tokens) {
|
|
|
64
67
|
cachedTokens = tokens;
|
|
65
68
|
return true;
|
|
66
69
|
} catch (error) {
|
|
67
|
-
|
|
70
|
+
log.note('auth', 'token-cache-save-failed');
|
|
71
|
+
log.debug('Error saving token cache:', error);
|
|
68
72
|
return false;
|
|
69
73
|
}
|
|
70
74
|
}
|