@littlebearapps/outlook-assistant 3.11.2 → 3.12.1

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.
Files changed (49) hide show
  1. package/.env.example +20 -0
  2. package/README.md +51 -28
  3. package/advanced/index.js +319 -46
  4. package/auth/device-code.js +100 -3
  5. package/auth/token-storage.js +44 -2
  6. package/auth/tools.js +196 -14
  7. package/calendar/attendees.js +101 -0
  8. package/calendar/cancel.js +5 -4
  9. package/calendar/create.js +15 -4
  10. package/calendar/decline.js +10 -5
  11. package/calendar/index.js +51 -10
  12. package/calendar/list.js +146 -3
  13. package/calendar/update.js +65 -33
  14. package/categories/index.js +17 -3
  15. package/config.js +103 -17
  16. package/contacts/index.js +2 -1
  17. package/email/attachments.js +19 -37
  18. package/email/conversations.js +180 -91
  19. package/email/delta.js +123 -13
  20. package/email/draft.js +66 -9
  21. package/email/export.js +113 -77
  22. package/email/folder-utils.js +29 -129
  23. package/email/headers.js +5 -1
  24. package/email/index.js +76 -19
  25. package/email/list.js +8 -1
  26. package/email/mark-as-read.js +3 -1
  27. package/email/mime.js +4 -1
  28. package/email/read.js +5 -1
  29. package/email/search.js +23 -9
  30. package/folder/create.js +11 -4
  31. package/folder/delete.js +9 -1
  32. package/folder/index.js +11 -1
  33. package/folder/list.js +61 -27
  34. package/folder/move.js +32 -7
  35. package/folder/resolve.js +65 -25
  36. package/folder/stats.js +11 -5
  37. package/index.js +9 -1
  38. package/llms-install.md +28 -9
  39. package/llms.txt +13 -9
  40. package/package.json +3 -3
  41. package/rules/index.js +3 -3
  42. package/rules/rule-builder.js +61 -16
  43. package/utils/datetime.js +170 -0
  44. package/utils/graph-api.js +390 -211
  45. package/utils/mailbox.js +77 -0
  46. package/utils/mock-data.js +3 -0
  47. package/utils/odata-helpers.js +24 -0
  48. package/utils/safe-write.js +151 -0
  49. package/calendar/accept.js +0 -72
package/advanced/index.js CHANGED
@@ -11,7 +11,21 @@
11
11
  const { callGraphAPI } = require('../utils/graph-api');
12
12
  const { ensureAuthenticated } = require('../auth');
13
13
  const { FIELD_PRESETS } = require('../utils/field-presets');
14
- const { DEFAULT_TIMEZONE } = require('../config');
14
+ const config = require('../config');
15
+ const { DEFAULT_TIMEZONE } = config;
16
+ const {
17
+ buildMailboxPrefix,
18
+ validateMailboxPrefix,
19
+ SHARED_MAILBOX_DISABLED_MESSAGE,
20
+ } = require('../utils/mailbox');
21
+ const { resolveFolder } = require('../folder/resolve');
22
+ const { getAllFoldersHierarchy } = require('../folder/list');
23
+ const {
24
+ InvalidDateTimeError,
25
+ toGraphDateTimeTimeZone,
26
+ zonedParts,
27
+ zonedWallTimeToUtcMs,
28
+ } = require('../utils/datetime');
15
29
 
16
30
  /**
17
31
  * Format an email for display (simplified)
@@ -42,13 +56,25 @@ function formatEmail(email, verbosity = 'standard') {
42
56
  }
43
57
 
44
58
  /**
45
- * Access shared mailbox handler
46
- * Requires Mail.Read.Shared permission
59
+ * Shared-mailbox hint appended to access errors while OUTLOOK_SHARED_MAILBOX
60
+ * is off (the token then carries no `.Shared` scope).
61
+ */
62
+ const ENABLE_SHARED_HINT =
63
+ '\n\nShared-mailbox scopes are not enabled. Reading a mailbox other than your own normally needs `Mail.Read.Shared`: set OUTLOOK_SHARED_MAILBOX=read (work/school accounts only), restart the server, and run `auth action=authenticate force=true`.';
64
+
65
+ /**
66
+ * Access shared mailbox handler.
67
+ *
68
+ * With OUTLOOK_SHARED_MAILBOX off this behaves exactly as before the opt-in
69
+ * flag existed: `folder` (or `folderId`) is used as given — a well-known name
70
+ * or a folder ID — with whatever scopes the token already has. With the flag
71
+ * on, custom/localized names and nested paths are resolved, and
72
+ * `listFolders` is available.
47
73
  */
48
74
  async function handleAccessSharedMailbox(args) {
49
75
  // F-46: accept `email` as alias for `sharedMailbox`. The original
50
76
  // param name is awkward; most callers reach for `email` first.
51
- const { folder, count, outputVerbosity } = args;
77
+ const { folder, folderId, count, outputVerbosity, listFolders } = args;
52
78
  const sharedMailbox = args.sharedMailbox || args.email;
53
79
 
54
80
  if (!sharedMailbox) {
@@ -62,6 +88,40 @@ async function handleAccessSharedMailbox(args) {
62
88
  };
63
89
  }
64
90
 
91
+ // Validate up front, through the same helper every other tool uses. The
92
+ // ungated variant keeps this tool's pre-opt-in behaviour when
93
+ // OUTLOOK_SHARED_MAILBOX is off; with it on, buildMailboxPrefix and
94
+ // validateMailboxPrefix agree.
95
+ let mailboxPrefix;
96
+ try {
97
+ mailboxPrefix = validateMailboxPrefix(sharedMailbox);
98
+ } catch (error) {
99
+ return { content: [{ type: 'text', text: error.message }] };
100
+ }
101
+ if (mailboxPrefix === 'me') {
102
+ return {
103
+ content: [
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
+ };
110
+ }
111
+
112
+ // listFolders mode: enumerate the shared mailbox's folder tree so callers
113
+ // can discover custom subfolder names/IDs to read from.
114
+ const sharedEnabled = config.SHARED_MAILBOX_MODE !== 'off';
115
+
116
+ if (listFolders) {
117
+ if (!sharedEnabled) {
118
+ return {
119
+ content: [{ type: 'text', text: SHARED_MAILBOX_DISABLED_MESSAGE }],
120
+ };
121
+ }
122
+ return handleListSharedMailboxFolders(sharedMailbox, args);
123
+ }
124
+
65
125
  const mailFolder = folder || 'inbox';
66
126
  const pageSize = Math.min(count || 25, 50);
67
127
  const verbosity = outputVerbosity || 'standard';
@@ -69,8 +129,45 @@ async function handleAccessSharedMailbox(args) {
69
129
  try {
70
130
  const accessToken = await ensureAuthenticated();
71
131
 
132
+ // Resolve the requested folder to an ID/well-known segment scoped to the
133
+ // shared mailbox. A raw `folderId` (from `listFolders`) is used as-is;
134
+ // otherwise custom and localized folder names are resolved via the tree.
135
+ let resolvedFolder;
136
+ if (!sharedEnabled) {
137
+ // Pre-opt-in behaviour: no folder resolution.
138
+ resolvedFolder = folderId || mailFolder;
139
+ } else {
140
+ try {
141
+ const resolved = await resolveFolder(accessToken, {
142
+ id: folderId,
143
+ name: mailFolder,
144
+ mailbox: sharedMailbox,
145
+ });
146
+ resolvedFolder = resolved.id;
147
+ } catch (resolveError) {
148
+ // Only resolution failures get the discovery hint — a Graph error
149
+ // (access denied, 5xx) must fall through to the generic handler below
150
+ // rather than masquerading as "folder not found".
151
+ if (!/not found|ambiguous/i.test(resolveError.message)) {
152
+ throw resolveError;
153
+ }
154
+ return {
155
+ content: [
156
+ {
157
+ type: 'text',
158
+ text:
159
+ `${resolveError.message}\n\n` +
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
+ };
166
+ }
167
+ }
168
+
72
169
  // Build endpoint for shared mailbox
73
- const endpoint = `users/${sharedMailbox}/mailFolders/${mailFolder}/messages`;
170
+ const endpoint = `${mailboxPrefix}/mailFolders/${resolvedFolder}/messages`;
74
171
  const fieldSet = verbosity === 'full' ? 'read' : 'list';
75
172
  const queryParams = {
76
173
  $top: pageSize.toString(),
@@ -165,7 +262,7 @@ async function handleAccessSharedMailbox(args) {
165
262
  content: [
166
263
  {
167
264
  type: 'text',
168
- 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`,
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}`,
169
266
  },
170
267
  ],
171
268
  };
@@ -193,6 +290,173 @@ async function handleAccessSharedMailbox(args) {
193
290
  }
194
291
  }
195
292
 
293
+ /**
294
+ * Enumerate a shared mailbox's folder hierarchy.
295
+ *
296
+ * Lists the full folder tree (recursively) of a shared/delegated mailbox so
297
+ * callers can discover custom subfolder names, paths, and IDs — the values
298
+ * needed to read or search those folders.
299
+ * @param {string} sharedMailbox - Shared mailbox email address
300
+ * @param {object} args - Tool arguments (outputVerbosity)
301
+ * @returns {object} - MCP response
302
+ */
303
+ async function handleListSharedMailboxFolders(sharedMailbox, args) {
304
+ const verbosity = args.outputVerbosity || 'standard';
305
+
306
+ try {
307
+ const accessToken = await ensureAuthenticated();
308
+
309
+ const { folders, warnings } = await getAllFoldersHierarchy(
310
+ accessToken,
311
+ true,
312
+ sharedMailbox
313
+ );
314
+
315
+ if (!folders || folders.length === 0) {
316
+ return {
317
+ content: [
318
+ {
319
+ type: 'text',
320
+ text: `No folders found in ${sharedMailbox}.\n\nNote: Make sure you have delegate access to this shared mailbox and the Mail.Read.Shared permission is granted.`,
321
+ },
322
+ ],
323
+ };
324
+ }
325
+
326
+ const output = [];
327
+ output.push(`# Shared Mailbox Folders: ${sharedMailbox}`);
328
+ output.push(
329
+ `**Folders**: ${folders.length}${warnings.length > 0 ? ' (partial)' : ''}\n`
330
+ );
331
+
332
+ folders.forEach((f) => {
333
+ const depth = f.path ? f.path.split('/').length - 1 : 0;
334
+ const indent = ' '.repeat(depth);
335
+ let line = `${indent}- ${f.displayName}`;
336
+ const total = f.totalItemCount || 0;
337
+ const unread = f.unreadItemCount || 0;
338
+ line += ` (${total} items${unread > 0 ? `, ${unread} unread` : ''})`;
339
+ output.push(line);
340
+ if (verbosity === 'full') {
341
+ output.push(`${indent} path: \`${f.path}\``);
342
+ output.push(`${indent} id: \`${f.id}\``);
343
+ }
344
+ });
345
+
346
+ if (warnings.length > 0) {
347
+ output.push(
348
+ `\n**Partial listing — ${warnings.length} branch(es) incomplete:**`
349
+ );
350
+ warnings.forEach((w) => output.push(`- ${w}`));
351
+ }
352
+
353
+ output.push(
354
+ '\nRead a folder with `access-shared-mailbox` using its name, ' +
355
+ 'path (e.g. `Inbox/Subfolder`), or `folderId`.'
356
+ );
357
+
358
+ return {
359
+ content: [
360
+ {
361
+ type: 'text',
362
+ text: output.join('\n'),
363
+ },
364
+ ],
365
+ _meta: {
366
+ sharedMailbox,
367
+ folderCount: folders.length,
368
+ partial: warnings.length > 0,
369
+ warnings,
370
+ folders: folders.map((f) => ({
371
+ id: f.id,
372
+ displayName: f.displayName,
373
+ folderPath: f.path,
374
+ parentFolderId: f.parentFolderId,
375
+ totalItemCount: f.totalItemCount,
376
+ unreadItemCount: f.unreadItemCount,
377
+ })),
378
+ },
379
+ };
380
+ } catch (error) {
381
+ 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
+ };
390
+ }
391
+
392
+ if (
393
+ error.message.includes('Access is denied') ||
394
+ error.message.includes('403')
395
+ ) {
396
+ return {
397
+ content: [
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
+ };
404
+ }
405
+
406
+ return {
407
+ content: [
408
+ {
409
+ type: 'text',
410
+ text: `Error listing shared mailbox folders: ${error.message}`,
411
+ },
412
+ ],
413
+ };
414
+ }
415
+ }
416
+
417
+ /**
418
+ * Default follow-up start for a flag with only a due date: 09:00 in
419
+ * DEFAULT_TIMEZONE on the due's local date, or the due itself when the due is
420
+ * earlier than that (a start after the due would be nonsense).
421
+ */
422
+ function deriveFlagStart(due) {
423
+ let wall;
424
+ if (due.timeZone === DEFAULT_TIMEZONE) {
425
+ wall = due.dateTime;
426
+ } else {
427
+ const parts = zonedParts(Date.parse(`${due.dateTime}Z`), DEFAULT_TIMEZONE);
428
+ if (!parts) return { ...due };
429
+ wall = `${parts.date}T${parts.time}`;
430
+ }
431
+ const nineAm = `${wall.slice(0, 10)}T09:00:00`;
432
+ // Same zone and fixed-width prefix, so string order is time order.
433
+ return wall < nineAm
434
+ ? { ...due }
435
+ : { dateTime: nineAm, timeZone: DEFAULT_TIMEZONE };
436
+ }
437
+
438
+ /**
439
+ * Describe a flag dateTimeTimeZone envelope as UTC plus DEFAULT_TIMEZONE,
440
+ * e.g. "2026-03-01 09:00 UTC (2026-03-01 20:00 Australia/Melbourne)".
441
+ */
442
+ function describeFlagTime(envelope) {
443
+ const ms =
444
+ envelope.timeZone === 'UTC'
445
+ ? Date.parse(`${envelope.dateTime}Z`)
446
+ : zonedWallTimeToUtcMs(envelope.dateTime, envelope.timeZone);
447
+ const local = (date, time) =>
448
+ `${date} ${time.slice(0, 5)} ${DEFAULT_TIMEZONE}`;
449
+ if (Number.isNaN(ms)) {
450
+ // DEFAULT_TIMEZONE isn't an IANA zone Intl knows; show it as given.
451
+ return local(envelope.dateTime.slice(0, 10), envelope.dateTime.slice(11));
452
+ }
453
+ const iso = new Date(ms).toISOString();
454
+ const utc = `${iso.slice(0, 10)} ${iso.slice(11, 16)} UTC`;
455
+ const parts =
456
+ DEFAULT_TIMEZONE === 'UTC' ? null : zonedParts(ms, DEFAULT_TIMEZONE);
457
+ return parts ? `${utc} (${local(parts.date, parts.time)})` : utc;
458
+ }
459
+
196
460
  /**
197
461
  * Set message flag handler
198
462
  */
@@ -204,6 +468,7 @@ async function handleSetMessageFlag(args) {
204
468
  startDateTime,
205
469
  reminderDateTime: _reminderDateTime,
206
470
  } = args;
471
+ const prefix = buildMailboxPrefix(args.sharedMailbox || args.email || null);
207
472
 
208
473
  // Support single ID or array
209
474
  const ids = messageIds || (messageId ? [messageId] : []);
@@ -219,43 +484,38 @@ async function handleSetMessageFlag(args) {
219
484
  };
220
485
  }
221
486
 
487
+ // Build flag object. Zoned values (Z/offset) are sent as the same instant in
488
+ // UTC; zone-less values are read in DEFAULT_TIMEZONE. Bad dates are refused
489
+ // here, before authenticating or calling Graph.
490
+ const flag = {
491
+ flagStatus: 'flagged',
492
+ };
222
493
  try {
223
- const accessToken = await ensureAuthenticated();
224
-
225
- // Build flag object
226
- const flag = {
227
- flagStatus: 'flagged',
228
- };
229
-
494
+ if (startDateTime) {
495
+ flag.startDateTime = toGraphDateTimeTimeZone(
496
+ startDateTime,
497
+ 'startDateTime'
498
+ );
499
+ }
230
500
  if (dueDateTime) {
231
- // Graph API expects { dateTime, timeZone } envelope without trailing Z
232
- // When timeZone is specified, the dateTime value is interpreted in that zone
233
- const dueDt = dueDateTime.replace(/Z$/i, '');
234
- flag.dueDateTime = {
235
- dateTime: dueDt,
236
- timeZone: DEFAULT_TIMEZONE,
237
- };
238
-
239
- // Graph API requires startDateTime when dueDateTime is set
240
- // Default to start of the same day if not explicitly provided
241
- if (startDateTime) {
242
- flag.startDateTime = {
243
- dateTime: startDateTime.replace(/Z$/i, ''),
244
- timeZone: DEFAULT_TIMEZONE,
245
- };
246
- } else {
247
- const startOfDay = `${dueDt.split('T')[0]}T09:00:00`;
248
- flag.startDateTime = {
249
- dateTime: startOfDay,
250
- timeZone: DEFAULT_TIMEZONE,
251
- };
501
+ flag.dueDateTime = toGraphDateTimeTimeZone(dueDateTime, 'dueDateTime');
502
+ // Graph requires startDateTime when dueDateTime is set.
503
+ if (!flag.startDateTime) {
504
+ flag.startDateTime = deriveFlagStart(flag.dueDateTime);
252
505
  }
253
- } else if (startDateTime) {
254
- flag.startDateTime = {
255
- dateTime: startDateTime.replace(/Z$/i, ''),
256
- timeZone: DEFAULT_TIMEZONE,
506
+ }
507
+ } catch (error) {
508
+ if (error instanceof InvalidDateTimeError) {
509
+ return {
510
+ content: [{ type: 'text', text: error.message }],
511
+ isError: true,
257
512
  };
258
513
  }
514
+ throw error;
515
+ }
516
+
517
+ try {
518
+ const accessToken = await ensureAuthenticated();
259
519
 
260
520
  // Process all messages
261
521
  const results = [];
@@ -263,7 +523,7 @@ async function handleSetMessageFlag(args) {
263
523
 
264
524
  for (const id of ids) {
265
525
  try {
266
- await callGraphAPI(accessToken, 'PATCH', `me/messages/${id}`, {
526
+ await callGraphAPI(accessToken, 'PATCH', `${prefix}/messages/${id}`, {
267
527
  flag,
268
528
  });
269
529
  results.push({ id, success: true });
@@ -277,11 +537,11 @@ async function handleSetMessageFlag(args) {
277
537
  if (results.length > 0) {
278
538
  output.push(`Flagged ${results.length} message(s) for follow-up`);
279
539
 
280
- if (dueDateTime) {
281
- output.push(`**Due**: ${new Date(dueDateTime).toLocaleString()}`);
540
+ if (flag.dueDateTime) {
541
+ output.push(`**Due**: ${describeFlagTime(flag.dueDateTime)}`);
282
542
  }
283
- if (startDateTime) {
284
- output.push(`**Start**: ${new Date(startDateTime).toLocaleString()}`);
543
+ if (flag.startDateTime) {
544
+ output.push(`**Start**: ${describeFlagTime(flag.startDateTime)}`);
285
545
  }
286
546
  }
287
547
 
@@ -333,6 +593,7 @@ async function handleSetMessageFlag(args) {
333
593
  */
334
594
  async function handleClearMessageFlag(args) {
335
595
  const { messageId, messageIds, markComplete } = args;
596
+ const prefix = buildMailboxPrefix(args.sharedMailbox || args.email || null);
336
597
 
337
598
  // Support single ID or array
338
599
  const ids = messageIds || (messageId ? [messageId] : []);
@@ -370,7 +631,7 @@ async function handleClearMessageFlag(args) {
370
631
 
371
632
  for (const id of ids) {
372
633
  try {
373
- await callGraphAPI(accessToken, 'PATCH', `me/messages/${id}`, {
634
+ await callGraphAPI(accessToken, 'PATCH', `${prefix}/messages/${id}`, {
374
635
  flag,
375
636
  });
376
637
  results.push({ id, success: true });
@@ -607,7 +868,7 @@ const advancedTools = [
607
868
  {
608
869
  name: 'access-shared-mailbox',
609
870
  description:
610
- 'List emails from a shared mailbox the signed-in user has been granted access to (read-only). Returns paged messages from the named `sharedMailbox` (or alias `email`) and `folder` (default `inbox`) with id/subject/from/receivedDateTime/preview — same shape as `search-emails` list mode. Requires that the shared mailbox has been delegated to the signed-in user in Exchange (admin-configured). Use `outputVerbosity` to control field count and `count` (default 25, max 50) for page size. For full search/filter capability over a shared mailbox, prefer `search-emails` with a folder path scoped to the shared mailbox.',
871
+ "List emails — or enumerate folders — from a shared mailbox the signed-in user has been granted access to (read-only). Returns paged messages from the named `sharedMailbox` (or alias `email`) and `folder` (default `inbox`) with id/subject/from/receivedDateTime/preview — same shape as `search-emails` list mode. `folder` accepts a well-known name (inbox, sent, archive…), a custom/localized folder display name (e.g. `Archiv`), a nested folder path (e.g. `Inbox/Vendors/Acme`), or pass a raw `folderId`. Set `listFolders: true` to enumerate the shared mailbox's full folder tree (names, paths, IDs, counts) — use this to discover custom subfolders before reading them. Requires that the shared mailbox has been delegated to the signed-in user in Exchange (admin-configured). Use `outputVerbosity` to control field count and `count` (default 25, max 50) for page size. For full search/filter capability over a shared mailbox, prefer `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, as before.",
611
872
  annotations: {
612
873
  title: 'Shared Mailbox',
613
874
  readOnlyHint: true,
@@ -629,7 +890,18 @@ const advancedTools = [
629
890
  },
630
891
  folder: {
631
892
  type: 'string',
632
- description: 'Folder to read from (default: inbox)',
893
+ description:
894
+ 'Folder to read from (default: inbox). Accepts a well-known name, a custom/localized display name, or a nested path like `Inbox/Subfolder`.',
895
+ },
896
+ folderId: {
897
+ type: 'string',
898
+ description:
899
+ 'Exact Graph folder ID to read from (e.g. from `listFolders`). Skips name resolution; takes precedence over `folder`.',
900
+ },
901
+ listFolders: {
902
+ type: 'boolean',
903
+ description:
904
+ "Enumerate the shared mailbox's full folder tree (names, paths, IDs, item counts) instead of reading messages.",
633
905
  },
634
906
  count: {
635
907
  type: 'number',
@@ -690,6 +962,7 @@ const advancedTools = [
690
962
  module.exports = {
691
963
  advancedTools,
692
964
  handleAccessSharedMailbox,
965
+ handleListSharedMailboxFolders,
693
966
  handleSetMessageFlag,
694
967
  handleClearMessageFlag,
695
968
  handleFindMeetingRooms,
@@ -73,10 +73,19 @@ async function initiateDeviceCodeFlow(clientId, scopes) {
73
73
  const { statusCode, body } = await postRequest(endpoint, postData);
74
74
 
75
75
  if (statusCode < 200 || statusCode >= 300) {
76
- throw new Error(
76
+ const error = new Error(
77
77
  body.error_description ||
78
78
  `Device code request failed with status ${statusCode}`
79
79
  );
80
+ // Same classification payload as pollForToken, so a scope rejection at
81
+ // initiation can be recognised by isScopeConsentError.
82
+ error.oauth = {
83
+ error: body.error,
84
+ error_codes: body.error_codes,
85
+ suberror: body.suberror,
86
+ error_description: body.error_description,
87
+ };
88
+ throw error;
80
89
  }
81
90
 
82
91
  return {
@@ -133,11 +142,22 @@ async function pollForToken(clientId, deviceCode, interval, expiresIn) {
133
142
  throw new Error(
134
143
  'Device code expired. Please restart the authentication process.'
135
144
  );
136
- default:
137
- throw new Error(
145
+ default: {
146
+ // Attach the raw OAuth payload so callers (e.g. handleDeviceCodeComplete)
147
+ // can classify the failure — notably scope-consent rejections that should
148
+ // trigger a base-scopes fallback. Keep the existing message text.
149
+ const e = new Error(
138
150
  body.error_description ||
139
151
  `Token polling failed: ${body.error || `status ${statusCode}`}`
140
152
  );
153
+ e.oauth = {
154
+ error: body.error,
155
+ error_codes: body.error_codes,
156
+ suberror: body.suberror,
157
+ error_description: body.error_description,
158
+ };
159
+ throw e;
160
+ }
141
161
  }
142
162
  }
143
163
 
@@ -146,7 +166,84 @@ async function pollForToken(clientId, deviceCode, interval, expiresIn) {
146
166
  );
147
167
  }
148
168
 
169
+ // AADSTS codes meaning "this scope value isn't supported for this account" —
170
+ // the only signals that justify a SILENT, DURABLE downgrade to base scopes:
171
+ // 650053 — "The application asked for scope '<x>' that doesn't exist on the
172
+ // resource" (the personal-account `.Shared` rejection)
173
+ // 70011 — invalid scope value
174
+ // Deliberately NOT here:
175
+ // 65001 — consent required (remediable: user/admin consent) → see
176
+ // isConsentRequiredError; must not silently strip capability
177
+ // 28000 — generic invalid request, not scope-specific
178
+ // invalid_grant (bare) — MFA/conditional access, revoked grant, tenant policy
179
+ const SCOPE_UNSUPPORTED_AADSTS_CODES = ['650053', '70011'];
180
+ const CONSENT_REQUIRED_AADSTS_CODES = ['65001'];
181
+
182
+ /**
183
+ * Does `err` carry one of `codes` in `oauth.error_codes` (array) or as an
184
+ * `AADSTS<code>` substring in `oauth.error_description` / `err.message`?
185
+ * @param {Error & {oauth?: object}} err
186
+ * @param {string[]} codes
187
+ * @returns {boolean}
188
+ */
189
+ // The OAuth error payload is attacker-influencable HTTP data — fields may
190
+ // arrive as arrays or objects instead of strings. Coerce before substring
191
+ // checks so `includes` is always String.prototype.includes.
192
+ function asString(value) {
193
+ return typeof value === 'string' ? value : '';
194
+ }
195
+
196
+ function hasAadstsCode(err, codes) {
197
+ const oauth = err.oauth || {};
198
+ if (Array.isArray(oauth.error_codes)) {
199
+ const found = oauth.error_codes.map(String);
200
+ if (found.some((c) => codes.includes(c))) {
201
+ return true;
202
+ }
203
+ }
204
+ const haystack = `${asString(oauth.error_description)} ${asString(err.message)}`;
205
+ // Whole-code match: `AADSTS70011` must not match `AADSTS700110`.
206
+ return codes.some((code) =>
207
+ new RegExp(`AADSTS${code}(?!\\d)`).test(haystack)
208
+ );
209
+ }
210
+
211
+ /**
212
+ * Predicate: is this a "requested scope isn't supported for this account"
213
+ * rejection that warrants falling back to base scopes? Deliberately narrow —
214
+ * a false positive silently and permanently strips shared-mailbox access.
215
+ * @param {Error & {oauth?: object}} err
216
+ * @returns {boolean}
217
+ */
218
+ function isScopeConsentError(err) {
219
+ if (!err) {
220
+ return false;
221
+ }
222
+ // Consent-required takes precedence: it is remediable, so it must surface
223
+ // rather than silently downgrade the scope set.
224
+ if (isConsentRequiredError(err)) {
225
+ return false;
226
+ }
227
+ const oauth = err.oauth || {};
228
+ return (
229
+ oauth.error === 'invalid_scope' ||
230
+ hasAadstsCode(err, SCOPE_UNSUPPORTED_AADSTS_CODES)
231
+ );
232
+ }
233
+
234
+ /**
235
+ * Predicate: consent required (AADSTS65001). Remediable via user/admin consent
236
+ * — surface it, never downgrade the scope set.
237
+ * @param {Error & {oauth?: object}} err
238
+ * @returns {boolean}
239
+ */
240
+ function isConsentRequiredError(err) {
241
+ return Boolean(err) && hasAadstsCode(err, CONSENT_REQUIRED_AADSTS_CODES);
242
+ }
243
+
149
244
  module.exports = {
150
245
  initiateDeviceCodeFlow,
151
246
  pollForToken,
247
+ isScopeConsentError,
248
+ isConsentRequiredError,
152
249
  };
@@ -5,6 +5,36 @@ const https = require('https');
5
5
  const querystring = require('querystring');
6
6
  const { describeAuthError } = require('./auth-errors');
7
7
 
8
+ /**
9
+ * Decide which scopes a refresh request should use. Prefer the scopes that were
10
+ * actually GRANTED (so a base-only fallback never re-requests `.Shared` on
11
+ * refresh and gets logged out ~1h later). Falls back to the parsed `scope`
12
+ * string, then to the configured scopes for back-compat with token files
13
+ * written before granted_scopes existed.
14
+ *
15
+ * `offline_access` is always included. Microsoft's token responses list only
16
+ * the scopes the access token is valid for — `offline_access` is not among
17
+ * them — and the token endpoint issues a new refresh_token only when
18
+ * `offline_access` is requested. Refreshing with the bare granted list would
19
+ * stop refresh-token rotation and eventually log the user out.
20
+ * @param {object|null} tokens - Stored token object
21
+ * @param {string[]} configScopes - Configured scope set (back-compat fallback)
22
+ * @returns {string[]} - Scopes to send in the refresh request
23
+ */
24
+ function resolveRefreshScopes(tokens, configScopes) {
25
+ let scopes = configScopes;
26
+ if (tokens) {
27
+ if (Array.isArray(tokens.granted_scopes) && tokens.granted_scopes.length) {
28
+ scopes = tokens.granted_scopes;
29
+ } else if (typeof tokens.scope === 'string' && tokens.scope.trim()) {
30
+ scopes = tokens.scope.split(' ').filter(Boolean);
31
+ }
32
+ }
33
+ return scopes.includes('offline_access')
34
+ ? scopes
35
+ : [...scopes, 'offline_access'];
36
+ }
37
+
8
38
  class TokenStorage {
9
39
  constructor(config) {
10
40
  this.config = {
@@ -179,7 +209,9 @@ class TokenStorage {
179
209
  client_id: this.config.clientId,
180
210
  grant_type: 'refresh_token',
181
211
  refresh_token: this.tokens.refresh_token,
182
- scope: this.config.scopes.join(' '),
212
+ // Use the GRANTED scopes, not the full configured set. After a base-only
213
+ // fallback, re-requesting `.Shared` here would fail and log the user out.
214
+ scope: resolveRefreshScopes(this.tokens, this.config.scopes).join(' '),
183
215
  };
184
216
  if (!isDeviceCode) {
185
217
  refreshParams.client_secret = this.config.clientSecret;
@@ -272,13 +304,14 @@ class TokenStorage {
272
304
  );
273
305
  }
274
306
  console.log('Exchanging authorization code for tokens...');
307
+ const requestedScopes = this.config.scopes;
275
308
  const postData = querystring.stringify({
276
309
  client_id: this.config.clientId,
277
310
  client_secret: this.config.clientSecret,
278
311
  grant_type: 'authorization_code',
279
312
  code: authCode,
280
313
  redirect_uri: this.config.redirectUri,
281
- scope: this.config.scopes.join(' '),
314
+ scope: requestedScopes.join(' '),
282
315
  });
283
316
 
284
317
  const requestOptions = {
@@ -306,6 +339,14 @@ class TokenStorage {
306
339
  expires_in: responseBody.expires_in,
307
340
  expires_at: Date.now() + responseBody.expires_in * 1000,
308
341
  scope: responseBody.scope,
342
+ // Persist granted scopes so refresh re-requests exactly what
343
+ // was granted (mirrors the device-code path). If the token
344
+ // response omits `scope`, fall back to what we requested.
345
+ granted_scopes:
346
+ typeof responseBody.scope === 'string' &&
347
+ responseBody.scope.trim()
348
+ ? responseBody.scope.split(' ').filter(Boolean)
349
+ : requestedScopes,
309
350
  token_type: responseBody.token_type,
310
351
  };
311
352
  try {
@@ -379,4 +420,5 @@ class TokenStorage {
379
420
  }
380
421
 
381
422
  module.exports = TokenStorage;
423
+ module.exports.resolveRefreshScopes = resolveRefreshScopes;
382
424
  // Adding a newline at the end of the file as requested by Gemini Code Assist