msteams-mcp 0.2.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.
Potentially problematic release.
This version of msteams-mcp might be problematic. Click here for more details.
- package/LICENSE +21 -0
- package/README.md +261 -0
- package/dist/__fixtures__/api-responses.d.ts +254 -0
- package/dist/__fixtures__/api-responses.js +245 -0
- package/dist/api/calendar-api.d.ts +66 -0
- package/dist/api/calendar-api.js +179 -0
- package/dist/api/chatsvc-api.d.ts +352 -0
- package/dist/api/chatsvc-api.js +1100 -0
- package/dist/api/csa-api.d.ts +64 -0
- package/dist/api/csa-api.js +200 -0
- package/dist/api/index.d.ts +7 -0
- package/dist/api/index.js +7 -0
- package/dist/api/substrate-api.d.ts +50 -0
- package/dist/api/substrate-api.js +305 -0
- package/dist/auth/crypto.d.ts +32 -0
- package/dist/auth/crypto.js +66 -0
- package/dist/auth/index.d.ts +7 -0
- package/dist/auth/index.js +7 -0
- package/dist/auth/session-store.d.ts +87 -0
- package/dist/auth/session-store.js +230 -0
- package/dist/auth/token-extractor.d.ts +185 -0
- package/dist/auth/token-extractor.js +674 -0
- package/dist/auth/token-refresh.d.ts +25 -0
- package/dist/auth/token-refresh.js +85 -0
- package/dist/browser/auth.d.ts +53 -0
- package/dist/browser/auth.js +603 -0
- package/dist/browser/context.d.ts +40 -0
- package/dist/browser/context.js +122 -0
- package/dist/constants.d.ts +104 -0
- package/dist/constants.js +195 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +12 -0
- package/dist/research/auth-research.d.ts +10 -0
- package/dist/research/auth-research.js +175 -0
- package/dist/research/explore.d.ts +11 -0
- package/dist/research/explore.js +270 -0
- package/dist/research/search-research.d.ts +17 -0
- package/dist/research/search-research.js +317 -0
- package/dist/server.d.ts +66 -0
- package/dist/server.js +295 -0
- package/dist/test/debug-search.d.ts +10 -0
- package/dist/test/debug-search.js +147 -0
- package/dist/test/mcp-harness.d.ts +17 -0
- package/dist/test/mcp-harness.js +474 -0
- package/dist/tools/auth-tools.d.ts +26 -0
- package/dist/tools/auth-tools.js +191 -0
- package/dist/tools/index.d.ts +56 -0
- package/dist/tools/index.js +34 -0
- package/dist/tools/meeting-tools.d.ts +33 -0
- package/dist/tools/meeting-tools.js +64 -0
- package/dist/tools/message-tools.d.ts +269 -0
- package/dist/tools/message-tools.js +856 -0
- package/dist/tools/people-tools.d.ts +46 -0
- package/dist/tools/people-tools.js +112 -0
- package/dist/tools/registry.d.ts +23 -0
- package/dist/tools/registry.js +63 -0
- package/dist/tools/search-tools.d.ts +91 -0
- package/dist/tools/search-tools.js +222 -0
- package/dist/types/errors.d.ts +58 -0
- package/dist/types/errors.js +132 -0
- package/dist/types/result.d.ts +43 -0
- package/dist/types/result.js +51 -0
- package/dist/types/server.d.ts +27 -0
- package/dist/types/server.js +7 -0
- package/dist/types/teams.d.ts +85 -0
- package/dist/types/teams.js +4 -0
- package/dist/utils/api-config.d.ts +103 -0
- package/dist/utils/api-config.js +158 -0
- package/dist/utils/auth-guards.d.ts +67 -0
- package/dist/utils/auth-guards.js +147 -0
- package/dist/utils/http.d.ts +29 -0
- package/dist/utils/http.js +112 -0
- package/dist/utils/parsers.d.ts +247 -0
- package/dist/utils/parsers.js +731 -0
- package/dist/utils/parsers.test.d.ts +7 -0
- package/dist/utils/parsers.test.js +511 -0
- package/package.json +62 -0
|
@@ -0,0 +1,603 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authentication handling for Microsoft Teams.
|
|
3
|
+
* Manages login detection and manual authentication flows.
|
|
4
|
+
*/
|
|
5
|
+
import { saveSessionState } from './context.js';
|
|
6
|
+
import { OVERLAY_STEP_PAUSE_MS, OVERLAY_COMPLETE_PAUSE_MS, } from '../constants.js';
|
|
7
|
+
/**
|
|
8
|
+
* Default Teams URL for initial login.
|
|
9
|
+
*
|
|
10
|
+
* For commercial tenants, this is teams.microsoft.com.
|
|
11
|
+
* For GCC/GCC-High/DoD tenants, Microsoft's login flow will redirect users
|
|
12
|
+
* to the appropriate URL (teams.microsoft.us, etc.) after authentication.
|
|
13
|
+
* We then extract the correct base URL from DISCOVER-REGION-GTM for all API calls.
|
|
14
|
+
*/
|
|
15
|
+
const TEAMS_URL = 'https://teams.microsoft.com';
|
|
16
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
17
|
+
// Progress Overlay UI
|
|
18
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
19
|
+
const PROGRESS_OVERLAY_ID = 'mcp-login-progress-overlay';
|
|
20
|
+
/** Content for each overlay phase. */
|
|
21
|
+
const OVERLAY_CONTENT = {
|
|
22
|
+
'signed-in': {
|
|
23
|
+
message: "You're signed in!",
|
|
24
|
+
detail: 'Setting up your connection to Teams...',
|
|
25
|
+
},
|
|
26
|
+
'acquiring': {
|
|
27
|
+
message: 'Acquiring permissions...',
|
|
28
|
+
detail: 'Getting access to search and messages...',
|
|
29
|
+
},
|
|
30
|
+
'saving': {
|
|
31
|
+
message: 'Saving your session...',
|
|
32
|
+
detail: "So you won't need to log in again.",
|
|
33
|
+
},
|
|
34
|
+
'complete': {
|
|
35
|
+
message: 'All done!',
|
|
36
|
+
detail: 'This window will close automatically.',
|
|
37
|
+
},
|
|
38
|
+
'refreshing': {
|
|
39
|
+
message: 'Refreshing your session...',
|
|
40
|
+
detail: 'Updating your access tokens...',
|
|
41
|
+
},
|
|
42
|
+
'error': {
|
|
43
|
+
message: 'Something went wrong',
|
|
44
|
+
detail: 'Please try again or check the console for details.',
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
/** Detail messages that cycle during the acquiring/refreshing phases. */
|
|
48
|
+
const ACQUIRING_DETAILS = [
|
|
49
|
+
'Preparing Teams connection...',
|
|
50
|
+
'Navigating to search...',
|
|
51
|
+
'Waiting for API response...',
|
|
52
|
+
'Acquiring search permissions...',
|
|
53
|
+
'Convincing Microsoft we mean well...',
|
|
54
|
+
'Negotiating with the UI...',
|
|
55
|
+
'Gathering auth tokens...',
|
|
56
|
+
'Good things come to those who wait...',
|
|
57
|
+
'Almost there...',
|
|
58
|
+
];
|
|
59
|
+
/** Interval for cycling detail messages (ms). */
|
|
60
|
+
const DETAIL_CYCLE_INTERVAL_MS = 3000;
|
|
61
|
+
/** ID for the detail text element (for cycling updates). */
|
|
62
|
+
const DETAIL_ELEMENT_ID = 'mcp-login-detail';
|
|
63
|
+
/**
|
|
64
|
+
* Shows a progress overlay for a specific phase.
|
|
65
|
+
* Handles injection, content, and optional pause.
|
|
66
|
+
* Failures are silently ignored - the overlay is purely cosmetic.
|
|
67
|
+
*/
|
|
68
|
+
async function showLoginProgress(page, phase, options = {}) {
|
|
69
|
+
const content = OVERLAY_CONTENT[phase];
|
|
70
|
+
const isComplete = phase === 'complete';
|
|
71
|
+
const isError = phase === 'error';
|
|
72
|
+
const isAnimated = phase === 'acquiring' || phase === 'refreshing';
|
|
73
|
+
try {
|
|
74
|
+
await page.evaluate(({ id, detailId, message, detail, complete, error, animated, cycleDetails, cycleInterval }) => {
|
|
75
|
+
// Remove existing overlay if present, clearing any running timer
|
|
76
|
+
const existing = document.getElementById(id);
|
|
77
|
+
if (existing) {
|
|
78
|
+
const existingWithTimer = existing;
|
|
79
|
+
if (existingWithTimer._cycleTimer) {
|
|
80
|
+
clearInterval(existingWithTimer._cycleTimer);
|
|
81
|
+
}
|
|
82
|
+
existing.remove();
|
|
83
|
+
}
|
|
84
|
+
// Remove any existing style element
|
|
85
|
+
const existingStyle = document.getElementById(`${id}-style`);
|
|
86
|
+
if (existingStyle) {
|
|
87
|
+
existingStyle.remove();
|
|
88
|
+
}
|
|
89
|
+
// Add keyframe animations for spinner
|
|
90
|
+
const style = document.createElement('style');
|
|
91
|
+
style.id = `${id}-style`;
|
|
92
|
+
style.textContent = `
|
|
93
|
+
@keyframes mcp-spin {
|
|
94
|
+
0% { transform: rotate(0deg); }
|
|
95
|
+
100% { transform: rotate(360deg); }
|
|
96
|
+
}
|
|
97
|
+
@keyframes mcp-pulse {
|
|
98
|
+
0%, 100% { opacity: 1; }
|
|
99
|
+
50% { opacity: 0.6; }
|
|
100
|
+
}
|
|
101
|
+
@keyframes mcp-fade {
|
|
102
|
+
0% { opacity: 0; transform: translateY(4px); }
|
|
103
|
+
15% { opacity: 1; transform: translateY(0); }
|
|
104
|
+
85% { opacity: 1; transform: translateY(0); }
|
|
105
|
+
100% { opacity: 0; transform: translateY(-4px); }
|
|
106
|
+
}
|
|
107
|
+
`;
|
|
108
|
+
document.head.appendChild(style);
|
|
109
|
+
// Create overlay container
|
|
110
|
+
const overlay = document.createElement('div');
|
|
111
|
+
overlay.id = id;
|
|
112
|
+
Object.assign(overlay.style, {
|
|
113
|
+
position: 'fixed',
|
|
114
|
+
top: '0',
|
|
115
|
+
left: '0',
|
|
116
|
+
right: '0',
|
|
117
|
+
bottom: '0',
|
|
118
|
+
background: 'rgba(0, 0, 0, 0.7)',
|
|
119
|
+
display: 'flex',
|
|
120
|
+
alignItems: 'center',
|
|
121
|
+
justifyContent: 'center',
|
|
122
|
+
zIndex: '999999',
|
|
123
|
+
fontFamily: "'Segoe UI', system-ui, sans-serif",
|
|
124
|
+
});
|
|
125
|
+
// Create modal card
|
|
126
|
+
const modal = document.createElement('div');
|
|
127
|
+
Object.assign(modal.style, {
|
|
128
|
+
background: 'white',
|
|
129
|
+
borderRadius: '12px',
|
|
130
|
+
padding: '40px 48px',
|
|
131
|
+
maxWidth: '420px',
|
|
132
|
+
textAlign: 'center',
|
|
133
|
+
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.3)',
|
|
134
|
+
});
|
|
135
|
+
// Create icon container (for animation)
|
|
136
|
+
const iconContainer = document.createElement('div');
|
|
137
|
+
Object.assign(iconContainer.style, {
|
|
138
|
+
width: '64px',
|
|
139
|
+
height: '64px',
|
|
140
|
+
margin: '0 auto 24px',
|
|
141
|
+
position: 'relative',
|
|
142
|
+
});
|
|
143
|
+
// Create icon
|
|
144
|
+
const icon = document.createElement('div');
|
|
145
|
+
const iconBg = error ? '#c42b1c' : complete ? '#107c10' : '#5b5fc7';
|
|
146
|
+
Object.assign(icon.style, {
|
|
147
|
+
width: '64px',
|
|
148
|
+
height: '64px',
|
|
149
|
+
borderRadius: '50%',
|
|
150
|
+
display: 'flex',
|
|
151
|
+
alignItems: 'center',
|
|
152
|
+
justifyContent: 'center',
|
|
153
|
+
fontSize: '32px',
|
|
154
|
+
background: iconBg,
|
|
155
|
+
color: 'white',
|
|
156
|
+
});
|
|
157
|
+
if (animated) {
|
|
158
|
+
// Spinner ring for animated states
|
|
159
|
+
const spinner = document.createElement('div');
|
|
160
|
+
Object.assign(spinner.style, {
|
|
161
|
+
position: 'absolute',
|
|
162
|
+
top: '-4px',
|
|
163
|
+
left: '-4px',
|
|
164
|
+
width: '72px',
|
|
165
|
+
height: '72px',
|
|
166
|
+
borderRadius: '50%',
|
|
167
|
+
border: '3px solid transparent',
|
|
168
|
+
borderTopColor: iconBg,
|
|
169
|
+
borderRightColor: iconBg,
|
|
170
|
+
animation: 'mcp-spin 1.2s linear infinite',
|
|
171
|
+
});
|
|
172
|
+
iconContainer.appendChild(spinner);
|
|
173
|
+
icon.textContent = '⋯';
|
|
174
|
+
icon.style.animation = 'mcp-pulse 2s ease-in-out infinite';
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
icon.textContent = error ? '✕' : complete ? '✓' : '⋯';
|
|
178
|
+
}
|
|
179
|
+
iconContainer.appendChild(icon);
|
|
180
|
+
// Create title
|
|
181
|
+
const title = document.createElement('h2');
|
|
182
|
+
Object.assign(title.style, {
|
|
183
|
+
margin: '0 0 12px',
|
|
184
|
+
fontSize: '20px',
|
|
185
|
+
fontWeight: '600',
|
|
186
|
+
color: '#242424',
|
|
187
|
+
});
|
|
188
|
+
title.textContent = message;
|
|
189
|
+
// Create detail text
|
|
190
|
+
const detailEl = document.createElement('p');
|
|
191
|
+
detailEl.id = detailId;
|
|
192
|
+
Object.assign(detailEl.style, {
|
|
193
|
+
margin: '0',
|
|
194
|
+
fontSize: '14px',
|
|
195
|
+
color: '#616161',
|
|
196
|
+
lineHeight: '1.5',
|
|
197
|
+
minHeight: '21px', // Prevent layout shift
|
|
198
|
+
});
|
|
199
|
+
if (animated) {
|
|
200
|
+
detailEl.style.animation = `mcp-fade ${cycleInterval}ms ease-in-out infinite`;
|
|
201
|
+
}
|
|
202
|
+
detailEl.textContent = detail;
|
|
203
|
+
// Assemble and append
|
|
204
|
+
modal.appendChild(iconContainer);
|
|
205
|
+
modal.appendChild(title);
|
|
206
|
+
modal.appendChild(detailEl);
|
|
207
|
+
overlay.appendChild(modal);
|
|
208
|
+
document.body.appendChild(overlay);
|
|
209
|
+
// Set up detail cycling for animated states
|
|
210
|
+
if (animated && cycleDetails && cycleDetails.length > 0) {
|
|
211
|
+
let detailIndex = 0;
|
|
212
|
+
const cycleTimer = setInterval(() => {
|
|
213
|
+
const el = document.getElementById(detailId);
|
|
214
|
+
if (el) {
|
|
215
|
+
el.textContent = cycleDetails[detailIndex];
|
|
216
|
+
detailIndex = (detailIndex + 1) % cycleDetails.length;
|
|
217
|
+
}
|
|
218
|
+
else {
|
|
219
|
+
clearInterval(cycleTimer);
|
|
220
|
+
}
|
|
221
|
+
}, cycleInterval);
|
|
222
|
+
// Store timer ID on overlay for potential future cleanup
|
|
223
|
+
overlay._cycleTimer = cycleTimer;
|
|
224
|
+
}
|
|
225
|
+
}, {
|
|
226
|
+
id: PROGRESS_OVERLAY_ID,
|
|
227
|
+
detailId: DETAIL_ELEMENT_ID,
|
|
228
|
+
message: content.message,
|
|
229
|
+
detail: content.detail,
|
|
230
|
+
complete: isComplete,
|
|
231
|
+
error: isError,
|
|
232
|
+
animated: isAnimated,
|
|
233
|
+
cycleDetails: isAnimated ? ACQUIRING_DETAILS : [],
|
|
234
|
+
cycleInterval: DETAIL_CYCLE_INTERVAL_MS,
|
|
235
|
+
});
|
|
236
|
+
// Pause if requested (for steps that need user to see the message)
|
|
237
|
+
if (options.pause) {
|
|
238
|
+
const pauseMs = isComplete ? OVERLAY_COMPLETE_PAUSE_MS : OVERLAY_STEP_PAUSE_MS;
|
|
239
|
+
await page.waitForTimeout(pauseMs);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
// Overlay is cosmetic - don't fail login if it can't be shown
|
|
244
|
+
// To debug: change to `catch (e)` and add `console.debug('[overlay]', e);`
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
248
|
+
// Authentication Detection
|
|
249
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
250
|
+
// URLs that indicate we're in a login flow
|
|
251
|
+
const LOGIN_URL_PATTERNS = [
|
|
252
|
+
'login.microsoftonline.com',
|
|
253
|
+
'login.live.com',
|
|
254
|
+
'login.microsoft.com',
|
|
255
|
+
];
|
|
256
|
+
// Selectors that indicate successful authentication
|
|
257
|
+
const AUTH_SUCCESS_SELECTORS = [
|
|
258
|
+
'[data-tid="app-bar"]',
|
|
259
|
+
'[data-tid="search-box"]',
|
|
260
|
+
'input[placeholder*="Search"]',
|
|
261
|
+
'[data-tid="chat-list"]',
|
|
262
|
+
'[data-tid="team-list"]',
|
|
263
|
+
];
|
|
264
|
+
/**
|
|
265
|
+
* Checks if the current page URL indicates a login flow.
|
|
266
|
+
*/
|
|
267
|
+
function isLoginUrl(url) {
|
|
268
|
+
return LOGIN_URL_PATTERNS.some(pattern => url.includes(pattern));
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Checks if the page shows authenticated Teams content.
|
|
272
|
+
*/
|
|
273
|
+
async function hasAuthenticatedContent(page) {
|
|
274
|
+
for (const selector of AUTH_SUCCESS_SELECTORS) {
|
|
275
|
+
try {
|
|
276
|
+
const count = await page.locator(selector).count();
|
|
277
|
+
if (count > 0) {
|
|
278
|
+
return true;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
// Selector not found, continue checking others
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return false;
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Triggers MSAL to acquire the Substrate token.
|
|
289
|
+
*
|
|
290
|
+
* MSAL only acquires tokens for specific scopes when the app makes API calls
|
|
291
|
+
* requiring those scopes. The Substrate API is only used for search, so we
|
|
292
|
+
* perform a minimal search ("is:Messages") to trigger token acquisition.
|
|
293
|
+
*/
|
|
294
|
+
async function triggerTokenAcquisition(page, log) {
|
|
295
|
+
log('Triggering token acquisition...');
|
|
296
|
+
try {
|
|
297
|
+
// Wait for the app to be ready
|
|
298
|
+
await page.waitForTimeout(5000);
|
|
299
|
+
// Try multiple methods to trigger search
|
|
300
|
+
let searchTriggered = false;
|
|
301
|
+
// Method 1: Navigate to search results URL (triggers Substrate API call directly)
|
|
302
|
+
log('Navigating to search results...');
|
|
303
|
+
try {
|
|
304
|
+
await page.goto('https://teams.microsoft.com/v2/#/search?query=test', {
|
|
305
|
+
waitUntil: 'domcontentloaded',
|
|
306
|
+
timeout: 30000,
|
|
307
|
+
});
|
|
308
|
+
await page.waitForTimeout(5000);
|
|
309
|
+
searchTriggered = true;
|
|
310
|
+
log('Search results page loaded.');
|
|
311
|
+
}
|
|
312
|
+
catch (e) {
|
|
313
|
+
log(`Search navigation failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
314
|
+
}
|
|
315
|
+
// Method 2: Fallback - focus and type
|
|
316
|
+
if (!searchTriggered) {
|
|
317
|
+
log('Trying focus+type fallback...');
|
|
318
|
+
try {
|
|
319
|
+
const focused = await page.evaluate(() => {
|
|
320
|
+
const selectors = [
|
|
321
|
+
'#ms-searchux-input',
|
|
322
|
+
'[data-tid="searchInputField"]',
|
|
323
|
+
'input[placeholder*="Search"]',
|
|
324
|
+
];
|
|
325
|
+
for (const sel of selectors) {
|
|
326
|
+
const el = document.querySelector(sel);
|
|
327
|
+
if (el) {
|
|
328
|
+
el.focus();
|
|
329
|
+
el.click();
|
|
330
|
+
return true;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return false;
|
|
334
|
+
});
|
|
335
|
+
if (focused) {
|
|
336
|
+
await page.waitForTimeout(500);
|
|
337
|
+
await page.keyboard.type('test', { delay: 30 });
|
|
338
|
+
await page.keyboard.press('Enter');
|
|
339
|
+
searchTriggered = true;
|
|
340
|
+
log('Search submitted via typing.');
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
catch {
|
|
344
|
+
// Continue
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
// Method 3: Keyboard shortcut fallback
|
|
348
|
+
if (!searchTriggered) {
|
|
349
|
+
log('Trying keyboard shortcut...');
|
|
350
|
+
const isMac = process.platform === 'darwin';
|
|
351
|
+
await page.keyboard.press(isMac ? 'Meta+e' : 'Control+e');
|
|
352
|
+
await page.waitForTimeout(1000);
|
|
353
|
+
await page.keyboard.type('is:Messages', { delay: 30 });
|
|
354
|
+
await page.keyboard.press('Enter');
|
|
355
|
+
searchTriggered = true;
|
|
356
|
+
}
|
|
357
|
+
// Wait for the Substrate search API call to complete
|
|
358
|
+
log('Waiting for search API...');
|
|
359
|
+
try {
|
|
360
|
+
// Wait for the actual API request to substrate.office.com
|
|
361
|
+
await Promise.race([
|
|
362
|
+
page.waitForResponse(resp => resp.url().includes('substrate.office.com') && resp.status() === 200, { timeout: 15000 }),
|
|
363
|
+
page.waitForTimeout(15000),
|
|
364
|
+
]);
|
|
365
|
+
log('Substrate API call detected.');
|
|
366
|
+
}
|
|
367
|
+
catch {
|
|
368
|
+
log('No Substrate API call detected, continuing...');
|
|
369
|
+
}
|
|
370
|
+
await page.waitForTimeout(2000);
|
|
371
|
+
// Close search and reset
|
|
372
|
+
await page.keyboard.press('Escape');
|
|
373
|
+
await page.waitForTimeout(1000);
|
|
374
|
+
log('Token acquisition complete.');
|
|
375
|
+
}
|
|
376
|
+
catch (error) {
|
|
377
|
+
log(`Token acquisition warning: ${error instanceof Error ? error.message : String(error)}`);
|
|
378
|
+
await page.waitForTimeout(3000);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Gets the current authentication status.
|
|
383
|
+
*/
|
|
384
|
+
export async function getAuthStatus(page) {
|
|
385
|
+
const currentUrl = page.url();
|
|
386
|
+
const onLoginPage = isLoginUrl(currentUrl);
|
|
387
|
+
// If on login page, definitely not authenticated
|
|
388
|
+
if (onLoginPage) {
|
|
389
|
+
return {
|
|
390
|
+
isAuthenticated: false,
|
|
391
|
+
isOnLoginPage: true,
|
|
392
|
+
currentUrl,
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
// If on Teams domain, check for authenticated content
|
|
396
|
+
if (currentUrl.includes('teams.microsoft.com')) {
|
|
397
|
+
const hasContent = await hasAuthenticatedContent(page);
|
|
398
|
+
return {
|
|
399
|
+
isAuthenticated: hasContent,
|
|
400
|
+
isOnLoginPage: false,
|
|
401
|
+
currentUrl,
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
// Unknown state
|
|
405
|
+
return {
|
|
406
|
+
isAuthenticated: false,
|
|
407
|
+
isOnLoginPage: false,
|
|
408
|
+
currentUrl,
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
/** Timeout for detecting login redirect (ms). */
|
|
412
|
+
const LOGIN_REDIRECT_TIMEOUT_MS = 5000;
|
|
413
|
+
/** URL patterns that indicate we're on a Teams page (not redirected elsewhere). */
|
|
414
|
+
const TEAMS_URL_PATTERNS = [
|
|
415
|
+
'teams.microsoft.com',
|
|
416
|
+
'teams.microsoft.us', // GCC-High
|
|
417
|
+
'dod.teams.microsoft.us', // DoD
|
|
418
|
+
'teams.cloud.microsoft', // New Teams URL
|
|
419
|
+
];
|
|
420
|
+
/**
|
|
421
|
+
* Checks if a URL is a Teams domain.
|
|
422
|
+
*/
|
|
423
|
+
function isTeamsUrl(url) {
|
|
424
|
+
return TEAMS_URL_PATTERNS.some(pattern => url.includes(pattern));
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* Navigates to Teams and checks authentication status.
|
|
428
|
+
*
|
|
429
|
+
* Uses a fast redirect-based detection: if we're not redirected to a login
|
|
430
|
+
* page within a few seconds, the session is valid. This is much faster than
|
|
431
|
+
* waiting for the full Teams SPA to render (which can take 30+ seconds).
|
|
432
|
+
*
|
|
433
|
+
* Returns isAuthenticated: false if we can't confirm we're on Teams, to avoid
|
|
434
|
+
* silently failing with an invisible browser stuck on an unexpected page.
|
|
435
|
+
*/
|
|
436
|
+
export async function navigateToTeams(page) {
|
|
437
|
+
// Set up a promise that resolves when we detect a login redirect
|
|
438
|
+
let redirectDetected = false;
|
|
439
|
+
// Handler for detecting login redirects
|
|
440
|
+
const handleFrameNavigated = (frame) => {
|
|
441
|
+
if (frame === page.mainFrame() && isLoginUrl(frame.url())) {
|
|
442
|
+
redirectDetected = true;
|
|
443
|
+
}
|
|
444
|
+
};
|
|
445
|
+
// Listen for navigation events
|
|
446
|
+
page.on('framenavigated', handleFrameNavigated);
|
|
447
|
+
try {
|
|
448
|
+
// Navigate to Teams
|
|
449
|
+
await page.goto(TEAMS_URL, { waitUntil: 'domcontentloaded' });
|
|
450
|
+
// Wait for either:
|
|
451
|
+
// 1. A redirect to login page (detected via framenavigated)
|
|
452
|
+
// 2. Timeout expires (no redirect = session valid)
|
|
453
|
+
//
|
|
454
|
+
// Research shows login redirect happens ~3-4 seconds after navigation
|
|
455
|
+
// when session is invalid (MSAL tries silent auth first, then redirects).
|
|
456
|
+
// 5 seconds gives enough buffer while still being fast.
|
|
457
|
+
const startTime = Date.now();
|
|
458
|
+
while (Date.now() - startTime < LOGIN_REDIRECT_TIMEOUT_MS) {
|
|
459
|
+
if (redirectDetected)
|
|
460
|
+
break;
|
|
461
|
+
await page.waitForTimeout(100); // Check every 100ms
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
finally {
|
|
465
|
+
// Clean up listener to avoid memory leaks
|
|
466
|
+
page.off('framenavigated', handleFrameNavigated);
|
|
467
|
+
}
|
|
468
|
+
// Check final state
|
|
469
|
+
const currentUrl = page.url();
|
|
470
|
+
// Definitely on login page
|
|
471
|
+
if (redirectDetected || isLoginUrl(currentUrl)) {
|
|
472
|
+
return {
|
|
473
|
+
isAuthenticated: false,
|
|
474
|
+
isOnLoginPage: true,
|
|
475
|
+
currentUrl,
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
// Verify we're actually on a Teams page (not some unexpected redirect)
|
|
479
|
+
// If we ended up somewhere unexpected, treat as unauthenticated to avoid
|
|
480
|
+
// silently failing with a headless browser stuck on the wrong page
|
|
481
|
+
if (!isTeamsUrl(currentUrl)) {
|
|
482
|
+
return {
|
|
483
|
+
isAuthenticated: false,
|
|
484
|
+
isOnLoginPage: false, // Not on login, but also not on Teams
|
|
485
|
+
currentUrl,
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
// On a Teams URL and no redirect to login = session is valid
|
|
489
|
+
return {
|
|
490
|
+
isAuthenticated: true,
|
|
491
|
+
isOnLoginPage: false,
|
|
492
|
+
currentUrl,
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* Waits for the user to complete manual authentication.
|
|
497
|
+
* Returns when authenticated or throws after timeout.
|
|
498
|
+
*
|
|
499
|
+
* @param page - The page to monitor
|
|
500
|
+
* @param context - Browser context for saving session
|
|
501
|
+
* @param timeoutMs - Maximum time to wait (default: 5 minutes)
|
|
502
|
+
* @param onProgress - Callback for progress updates
|
|
503
|
+
* @param showOverlay - Whether to show progress overlay (default: true for visible browsers)
|
|
504
|
+
*/
|
|
505
|
+
export async function waitForManualLogin(page, context, timeoutMs = 5 * 60 * 1000, onProgress, showOverlay = true) {
|
|
506
|
+
const startTime = Date.now();
|
|
507
|
+
const log = onProgress ?? console.log;
|
|
508
|
+
log('Waiting for manual login...');
|
|
509
|
+
while (Date.now() - startTime < timeoutMs) {
|
|
510
|
+
const status = await getAuthStatus(page);
|
|
511
|
+
if (status.isAuthenticated) {
|
|
512
|
+
log('Authentication successful!');
|
|
513
|
+
// Show progress through login steps (only if overlay enabled)
|
|
514
|
+
if (showOverlay) {
|
|
515
|
+
await showLoginProgress(page, 'signed-in', { pause: true });
|
|
516
|
+
await showLoginProgress(page, 'acquiring');
|
|
517
|
+
}
|
|
518
|
+
// Trigger a search to cause MSAL to acquire the Substrate token
|
|
519
|
+
await triggerTokenAcquisition(page, log);
|
|
520
|
+
if (showOverlay) {
|
|
521
|
+
await showLoginProgress(page, 'saving');
|
|
522
|
+
}
|
|
523
|
+
// Save the session state with fresh tokens
|
|
524
|
+
await saveSessionState(context);
|
|
525
|
+
log('Session state saved.');
|
|
526
|
+
if (showOverlay) {
|
|
527
|
+
await showLoginProgress(page, 'complete', { pause: true });
|
|
528
|
+
}
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
// Check every 2 seconds
|
|
532
|
+
await page.waitForTimeout(2000);
|
|
533
|
+
}
|
|
534
|
+
// Show error overlay before throwing (only if overlay enabled)
|
|
535
|
+
if (showOverlay) {
|
|
536
|
+
await showLoginProgress(page, 'error', { pause: true });
|
|
537
|
+
}
|
|
538
|
+
throw new Error('Authentication timeout: user did not complete login within the allowed time');
|
|
539
|
+
}
|
|
540
|
+
/**
|
|
541
|
+
* Performs a full authentication flow:
|
|
542
|
+
* 1. Navigate to Teams
|
|
543
|
+
* 2. Check if already authenticated
|
|
544
|
+
* 3. If not, wait for manual login (or throw if headless)
|
|
545
|
+
*
|
|
546
|
+
* @param page - The page to use
|
|
547
|
+
* @param context - Browser context for session management
|
|
548
|
+
* @param onProgress - Callback for progress updates
|
|
549
|
+
* @param showOverlay - Whether to show progress overlay (default: true for visible browsers)
|
|
550
|
+
* @param headless - If true, throw immediately if user interaction is required (default: false)
|
|
551
|
+
*/
|
|
552
|
+
export async function ensureAuthenticated(page, context, onProgress, showOverlay = true, headless = false) {
|
|
553
|
+
const log = onProgress ?? console.log;
|
|
554
|
+
log('Navigating to Teams...');
|
|
555
|
+
const status = await navigateToTeams(page);
|
|
556
|
+
if (status.isAuthenticated) {
|
|
557
|
+
log('Already authenticated.');
|
|
558
|
+
if (showOverlay) {
|
|
559
|
+
await showLoginProgress(page, 'refreshing');
|
|
560
|
+
}
|
|
561
|
+
// Trigger a search to cause MSAL to acquire/refresh the Substrate token
|
|
562
|
+
await triggerTokenAcquisition(page, log);
|
|
563
|
+
if (showOverlay) {
|
|
564
|
+
await showLoginProgress(page, 'saving');
|
|
565
|
+
}
|
|
566
|
+
// Save the session state with fresh tokens
|
|
567
|
+
await saveSessionState(context);
|
|
568
|
+
if (showOverlay) {
|
|
569
|
+
await showLoginProgress(page, 'complete', { pause: true });
|
|
570
|
+
}
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
// User interaction required - fail fast if headless
|
|
574
|
+
if (headless) {
|
|
575
|
+
const reason = status.isOnLoginPage
|
|
576
|
+
? 'Login page detected - user credentials required'
|
|
577
|
+
: `Unexpected page state: ${status.currentUrl}`;
|
|
578
|
+
throw new Error(`Headless SSO failed: ${reason}`);
|
|
579
|
+
}
|
|
580
|
+
if (status.isOnLoginPage) {
|
|
581
|
+
log('Login required. Please complete authentication in the browser window.');
|
|
582
|
+
await waitForManualLogin(page, context, undefined, onProgress, showOverlay);
|
|
583
|
+
// Navigate back to Teams after login (in case we're on a callback URL)
|
|
584
|
+
await navigateToTeams(page);
|
|
585
|
+
}
|
|
586
|
+
else {
|
|
587
|
+
// Unexpected state - might need manual intervention
|
|
588
|
+
log('Unexpected page state. Waiting for authentication...');
|
|
589
|
+
await waitForManualLogin(page, context, undefined, onProgress, showOverlay);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* Forces a new login by clearing session and navigating to Teams.
|
|
594
|
+
*/
|
|
595
|
+
export async function forceNewLogin(page, context, onProgress) {
|
|
596
|
+
const log = onProgress ?? console.log;
|
|
597
|
+
log('Starting fresh login...');
|
|
598
|
+
// Clear cookies to force re-authentication
|
|
599
|
+
await context.clearCookies();
|
|
600
|
+
// Navigate and wait for login
|
|
601
|
+
await navigateToTeams(page);
|
|
602
|
+
await waitForManualLogin(page, context, undefined, onProgress);
|
|
603
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Playwright browser context management.
|
|
3
|
+
* Creates and manages browser contexts with session persistence.
|
|
4
|
+
*
|
|
5
|
+
* Uses the system's installed Chrome or Edge browser rather than downloading
|
|
6
|
+
* Playwright's bundled Chromium. This significantly reduces install size.
|
|
7
|
+
*/
|
|
8
|
+
import { type Browser, type BrowserContext, type Page } from 'playwright';
|
|
9
|
+
export interface BrowserManager {
|
|
10
|
+
browser: Browser;
|
|
11
|
+
context: BrowserContext;
|
|
12
|
+
page: Page;
|
|
13
|
+
isNewSession: boolean;
|
|
14
|
+
}
|
|
15
|
+
export interface CreateBrowserOptions {
|
|
16
|
+
headless?: boolean;
|
|
17
|
+
viewport?: {
|
|
18
|
+
width: number;
|
|
19
|
+
height: number;
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Creates a browser context with optional session state restoration.
|
|
24
|
+
*
|
|
25
|
+
* Uses the system's installed Chrome or Edge browser rather than downloading
|
|
26
|
+
* Playwright's bundled Chromium (~180MB savings).
|
|
27
|
+
*
|
|
28
|
+
* @param options - Browser configuration options
|
|
29
|
+
* @returns Browser manager with browser, context, and page
|
|
30
|
+
* @throws Error if system browser is not found (with helpful suggestions)
|
|
31
|
+
*/
|
|
32
|
+
export declare function createBrowserContext(options?: CreateBrowserOptions): Promise<BrowserManager>;
|
|
33
|
+
/**
|
|
34
|
+
* Saves the current browser context's session state.
|
|
35
|
+
*/
|
|
36
|
+
export declare function saveSessionState(context: BrowserContext): Promise<void>;
|
|
37
|
+
/**
|
|
38
|
+
* Closes the browser and optionally saves session state.
|
|
39
|
+
*/
|
|
40
|
+
export declare function closeBrowser(manager: BrowserManager, saveSession?: boolean): Promise<void>;
|