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,674 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token extraction from session state.
|
|
3
|
+
*
|
|
4
|
+
* Extracts various authentication tokens from Playwright's saved session state.
|
|
5
|
+
* Teams stores MSAL tokens in localStorage; we parse these to get bearer tokens
|
|
6
|
+
* for various APIs (Substrate search, chatsvc messaging, etc.).
|
|
7
|
+
*/
|
|
8
|
+
import { readSessionState, readTokenCache, writeTokenCache, clearTokenCache, getTeamsOrigin, } from './session-store.js';
|
|
9
|
+
import { parseJwtProfile } from '../utils/parsers.js';
|
|
10
|
+
import { MRI_TYPE_PREFIX, ORGID_PREFIX, MRI_ORGID_PREFIX, MAX_DEBUG_CONFIG_VALUE_LENGTH } from '../constants.js';
|
|
11
|
+
// ============================================================================
|
|
12
|
+
// JWT Utilities
|
|
13
|
+
// ============================================================================
|
|
14
|
+
/**
|
|
15
|
+
* Decodes a JWT token's payload without verifying the signature.
|
|
16
|
+
*/
|
|
17
|
+
function decodeJwtPayload(token) {
|
|
18
|
+
try {
|
|
19
|
+
const parts = token.split('.');
|
|
20
|
+
if (parts.length < 2)
|
|
21
|
+
return null;
|
|
22
|
+
return JSON.parse(Buffer.from(parts[1], 'base64').toString());
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Gets the expiry date from a JWT token's `exp` claim.
|
|
30
|
+
*/
|
|
31
|
+
function getJwtExpiry(token) {
|
|
32
|
+
const payload = decodeJwtPayload(token);
|
|
33
|
+
if (!payload?.exp || typeof payload.exp !== 'number')
|
|
34
|
+
return null;
|
|
35
|
+
return new Date(payload.exp * 1000);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Checks if a string looks like a JWT (starts with 'ey').
|
|
39
|
+
*/
|
|
40
|
+
function isJwtToken(value) {
|
|
41
|
+
return typeof value === 'string' && value.startsWith('ey');
|
|
42
|
+
}
|
|
43
|
+
// ============================================================================
|
|
44
|
+
// Session Helpers
|
|
45
|
+
// ============================================================================
|
|
46
|
+
/**
|
|
47
|
+
* Resolves session state and Teams origin in one call.
|
|
48
|
+
* Many functions need both, so this reduces boilerplate.
|
|
49
|
+
*/
|
|
50
|
+
function getTeamsLocalStorage(state) {
|
|
51
|
+
const sessionState = state ?? readSessionState();
|
|
52
|
+
if (!sessionState)
|
|
53
|
+
return null;
|
|
54
|
+
const teamsOrigin = getTeamsOrigin(sessionState);
|
|
55
|
+
return teamsOrigin?.localStorage ?? null;
|
|
56
|
+
}
|
|
57
|
+
// ============================================================================
|
|
58
|
+
// Token Extraction
|
|
59
|
+
// ============================================================================
|
|
60
|
+
/**
|
|
61
|
+
* Extracts the Substrate search token from session state.
|
|
62
|
+
* This token is used for search and people APIs.
|
|
63
|
+
*/
|
|
64
|
+
export function extractSubstrateToken(state) {
|
|
65
|
+
const localStorage = getTeamsLocalStorage(state);
|
|
66
|
+
if (!localStorage)
|
|
67
|
+
return null;
|
|
68
|
+
// Collect all valid Substrate tokens and pick the one with longest expiry
|
|
69
|
+
let bestToken = null;
|
|
70
|
+
for (const item of localStorage) {
|
|
71
|
+
try {
|
|
72
|
+
const entry = JSON.parse(item.value);
|
|
73
|
+
// Look for Substrate search tokens by target scope
|
|
74
|
+
// Match both old format (substrate.office.com/search/SubstrateSearch)
|
|
75
|
+
// and new format (substrate.office.com/SubstrateSearch-Internal.ReadWrite)
|
|
76
|
+
const target = entry.target;
|
|
77
|
+
if (!target?.includes('substrate.office.com'))
|
|
78
|
+
continue;
|
|
79
|
+
if (!target.includes('SubstrateSearch'))
|
|
80
|
+
continue;
|
|
81
|
+
if (!isJwtToken(entry.secret))
|
|
82
|
+
continue;
|
|
83
|
+
const expiry = getJwtExpiry(entry.secret);
|
|
84
|
+
if (!expiry)
|
|
85
|
+
continue;
|
|
86
|
+
// Skip expired tokens
|
|
87
|
+
if (expiry.getTime() <= Date.now())
|
|
88
|
+
continue;
|
|
89
|
+
// Keep the token with longest remaining validity
|
|
90
|
+
if (!bestToken || expiry.getTime() > bestToken.expiry.getTime()) {
|
|
91
|
+
bestToken = { token: entry.secret, expiry };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return bestToken;
|
|
99
|
+
}
|
|
100
|
+
// ============================================================================
|
|
101
|
+
// Cached Token Access
|
|
102
|
+
// ============================================================================
|
|
103
|
+
/**
|
|
104
|
+
* Gets a valid Substrate token, either from cache or by extracting from session.
|
|
105
|
+
*/
|
|
106
|
+
export function getValidSubstrateToken() {
|
|
107
|
+
// Try cache first
|
|
108
|
+
const cache = readTokenCache();
|
|
109
|
+
if (cache && cache.substrateTokenExpiry > Date.now()) {
|
|
110
|
+
return cache.substrateToken;
|
|
111
|
+
}
|
|
112
|
+
// Extract from session
|
|
113
|
+
const extracted = extractSubstrateToken();
|
|
114
|
+
if (!extracted)
|
|
115
|
+
return null;
|
|
116
|
+
// Check if not expired
|
|
117
|
+
if (extracted.expiry.getTime() <= Date.now()) {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
// Cache the token
|
|
121
|
+
const newCache = {
|
|
122
|
+
substrateToken: extracted.token,
|
|
123
|
+
substrateTokenExpiry: extracted.expiry.getTime(),
|
|
124
|
+
extractedAt: Date.now(),
|
|
125
|
+
};
|
|
126
|
+
writeTokenCache(newCache);
|
|
127
|
+
return extracted.token;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Checks if we have a valid Substrate token.
|
|
131
|
+
*/
|
|
132
|
+
export function hasValidSubstrateToken() {
|
|
133
|
+
return getValidSubstrateToken() !== null;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Gets Substrate token status for diagnostics.
|
|
137
|
+
*/
|
|
138
|
+
export function getSubstrateTokenStatus() {
|
|
139
|
+
const extracted = extractSubstrateToken();
|
|
140
|
+
if (!extracted) {
|
|
141
|
+
return { hasToken: false };
|
|
142
|
+
}
|
|
143
|
+
const now = Date.now();
|
|
144
|
+
const expiryMs = extracted.expiry.getTime();
|
|
145
|
+
return {
|
|
146
|
+
hasToken: expiryMs > now,
|
|
147
|
+
expiresAt: extracted.expiry.toISOString(),
|
|
148
|
+
minutesRemaining: Math.max(0, Math.round((expiryMs - now) / 1000 / 60)),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Extracts the Teams chat API token from session state.
|
|
153
|
+
*
|
|
154
|
+
* Teams stores multiple tokens for different services. We prefer:
|
|
155
|
+
* 1. chatsvcagg.teams.microsoft.com (primary chat API)
|
|
156
|
+
* 2. api.spaces.skype.com (fallback)
|
|
157
|
+
*/
|
|
158
|
+
export function extractTeamsToken(state) {
|
|
159
|
+
const localStorage = getTeamsLocalStorage(state);
|
|
160
|
+
if (!localStorage)
|
|
161
|
+
return null;
|
|
162
|
+
let chatsvcCandidate = null;
|
|
163
|
+
let skypeCandidate = null;
|
|
164
|
+
let userMri = null;
|
|
165
|
+
for (const item of localStorage) {
|
|
166
|
+
try {
|
|
167
|
+
const entry = JSON.parse(item.value);
|
|
168
|
+
if (!entry.target || !isJwtToken(entry.secret))
|
|
169
|
+
continue;
|
|
170
|
+
const payload = decodeJwtPayload(entry.secret);
|
|
171
|
+
if (!payload?.exp || typeof payload.exp !== 'number')
|
|
172
|
+
continue;
|
|
173
|
+
const expiry = new Date(payload.exp * 1000);
|
|
174
|
+
// Capture user MRI from any token's oid claim
|
|
175
|
+
if (typeof payload.oid === 'string' && !userMri) {
|
|
176
|
+
userMri = `${MRI_ORGID_PREFIX}${payload.oid}`;
|
|
177
|
+
}
|
|
178
|
+
// Track best candidate for each service
|
|
179
|
+
if (entry.target.includes('chatsvcagg.teams.microsoft.com')) {
|
|
180
|
+
if (!chatsvcCandidate || expiry > chatsvcCandidate.expiry) {
|
|
181
|
+
chatsvcCandidate = { token: entry.secret, expiry };
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
else if (entry.target.includes('api.spaces.skype.com')) {
|
|
185
|
+
if (!skypeCandidate || expiry > skypeCandidate.expiry) {
|
|
186
|
+
skypeCandidate = { token: entry.secret, expiry };
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
// Fallback: extract userMri from Substrate token if not found
|
|
195
|
+
if (!userMri) {
|
|
196
|
+
userMri = extractUserMriFromSubstrate(state);
|
|
197
|
+
}
|
|
198
|
+
// Prefer chatsvc, fall back to skype
|
|
199
|
+
const best = chatsvcCandidate ?? skypeCandidate;
|
|
200
|
+
if (!best || !userMri || best.expiry.getTime() <= Date.now()) {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
return { token: best.token, expiry: best.expiry, userMri };
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Extracts the Skype Spaces API token from session state.
|
|
207
|
+
*
|
|
208
|
+
* This token is required for the calendar/meetings API (mt/part endpoints).
|
|
209
|
+
* It has scope: https://api.spaces.skype.com/Authorization.ReadWrite
|
|
210
|
+
*/
|
|
211
|
+
export function extractSkypeSpacesToken(state) {
|
|
212
|
+
const localStorage = getTeamsLocalStorage(state);
|
|
213
|
+
if (!localStorage)
|
|
214
|
+
return null;
|
|
215
|
+
let bestCandidate = null;
|
|
216
|
+
for (const item of localStorage) {
|
|
217
|
+
try {
|
|
218
|
+
const entry = JSON.parse(item.value);
|
|
219
|
+
if (!entry.target || !isJwtToken(entry.secret))
|
|
220
|
+
continue;
|
|
221
|
+
// Look for api.spaces.skype.com token
|
|
222
|
+
if (!entry.target.includes('api.spaces.skype.com'))
|
|
223
|
+
continue;
|
|
224
|
+
const payload = decodeJwtPayload(entry.secret);
|
|
225
|
+
if (!payload?.exp || typeof payload.exp !== 'number')
|
|
226
|
+
continue;
|
|
227
|
+
const expiry = new Date(payload.exp * 1000);
|
|
228
|
+
// Skip expired tokens
|
|
229
|
+
if (expiry.getTime() <= Date.now())
|
|
230
|
+
continue;
|
|
231
|
+
// Keep the one with the latest expiry
|
|
232
|
+
if (!bestCandidate || expiry > bestCandidate.expiry) {
|
|
233
|
+
bestCandidate = { token: entry.secret, expiry };
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return bestCandidate?.token ?? null;
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Extracts the user's region and partition from the Teams discovery config.
|
|
244
|
+
*
|
|
245
|
+
* Teams stores a DISCOVER-REGION-GTM config in localStorage that contains
|
|
246
|
+
* region-specific URLs for all APIs. There are two formats:
|
|
247
|
+
*
|
|
248
|
+
* **Partitioned (most Enterprise tenants):**
|
|
249
|
+
* - middleTier: "https://teams.microsoft.com/api/mt/part/amer-02"
|
|
250
|
+
* - chatServiceAfd: "https://teams.microsoft.com/api/chatsvc/amer"
|
|
251
|
+
*
|
|
252
|
+
* **Non-partitioned (some tenants, e.g., UK):**
|
|
253
|
+
* - middleTier: "https://teams.microsoft.com/api/mt/emea"
|
|
254
|
+
* - chatServiceAfd: "https://teams.microsoft.com/api/chatsvc/uk"
|
|
255
|
+
*
|
|
256
|
+
* We use the full URLs directly from config rather than reconstructing them,
|
|
257
|
+
* which ensures compatibility with GCC/GCC-High tenants that may use different
|
|
258
|
+
* base URLs (e.g., teams.microsoft.us).
|
|
259
|
+
*/
|
|
260
|
+
export function extractRegionConfig(state) {
|
|
261
|
+
const localStorage = getTeamsLocalStorage(state);
|
|
262
|
+
if (!localStorage)
|
|
263
|
+
return null;
|
|
264
|
+
// Find the DISCOVER-REGION-GTM key
|
|
265
|
+
for (const item of localStorage) {
|
|
266
|
+
if (!item.name.includes('DISCOVER-REGION-GTM'))
|
|
267
|
+
continue;
|
|
268
|
+
try {
|
|
269
|
+
const data = JSON.parse(item.value);
|
|
270
|
+
const middleTierUrl = data.item?.middleTier;
|
|
271
|
+
const chatServiceUrl = data.item?.chatServiceAfd;
|
|
272
|
+
const csaServiceUrl = data.item?.chatSvcAggAfd;
|
|
273
|
+
if (!chatServiceUrl)
|
|
274
|
+
continue;
|
|
275
|
+
// Extract Teams base URL from any of the URLs (chatServiceAfd is reliable)
|
|
276
|
+
let teamsBaseUrl = 'https://teams.microsoft.com'; // fallback
|
|
277
|
+
try {
|
|
278
|
+
const url = new URL(chatServiceUrl);
|
|
279
|
+
teamsBaseUrl = `${url.protocol}//${url.host}`;
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
// Use fallback
|
|
283
|
+
}
|
|
284
|
+
// Extract region from chatServiceAfd (e.g., /api/chatsvc/amer or /api/chatsvc/uk)
|
|
285
|
+
const chatMatch = chatServiceUrl.match(/\/api\/chatsvc\/([a-z]+)$/);
|
|
286
|
+
if (!chatMatch)
|
|
287
|
+
continue;
|
|
288
|
+
const region = chatMatch[1];
|
|
289
|
+
// Try to extract partition from middleTier if it's partitioned
|
|
290
|
+
// Format: /api/mt/part/amer-02 (partitioned) or /api/mt/emea (non-partitioned)
|
|
291
|
+
let partition;
|
|
292
|
+
let regionPartition;
|
|
293
|
+
let hasPartition = false;
|
|
294
|
+
if (middleTierUrl) {
|
|
295
|
+
const partitionMatch = middleTierUrl.match(/\/api\/mt\/part\/([a-z]+)-(\d+)$/);
|
|
296
|
+
if (partitionMatch) {
|
|
297
|
+
hasPartition = true;
|
|
298
|
+
partition = partitionMatch[2];
|
|
299
|
+
regionPartition = `${partitionMatch[1]}-${partition}`;
|
|
300
|
+
}
|
|
301
|
+
else {
|
|
302
|
+
// Non-partitioned format: /api/mt/emea
|
|
303
|
+
const simpleMatch = middleTierUrl.match(/\/api\/mt\/([a-z]+)$/);
|
|
304
|
+
if (simpleMatch) {
|
|
305
|
+
// No partition - calendar API uses non-partitioned URL
|
|
306
|
+
regionPartition = simpleMatch[1];
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return {
|
|
311
|
+
region,
|
|
312
|
+
partition: partition ?? '',
|
|
313
|
+
regionPartition: regionPartition ?? region,
|
|
314
|
+
hasPartition,
|
|
315
|
+
middleTierUrl: middleTierUrl ?? '',
|
|
316
|
+
chatServiceUrl,
|
|
317
|
+
csaServiceUrl: csaServiceUrl ?? `${teamsBaseUrl}/api/csa/${region}`,
|
|
318
|
+
teamsBaseUrl,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
catch {
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return null;
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Extracts user details from DISCOVER-USER-DETAILS in localStorage.
|
|
329
|
+
*
|
|
330
|
+
* This provides user-specific info including:
|
|
331
|
+
* - User's MRI
|
|
332
|
+
* - Region and partition info
|
|
333
|
+
* - License details (Copilot, transcription, etc.)
|
|
334
|
+
*/
|
|
335
|
+
export function extractUserDetails(state) {
|
|
336
|
+
const localStorage = getTeamsLocalStorage(state);
|
|
337
|
+
if (!localStorage)
|
|
338
|
+
return null;
|
|
339
|
+
for (const item of localStorage) {
|
|
340
|
+
if (!item.name.includes('DISCOVER-USER-DETAILS'))
|
|
341
|
+
continue;
|
|
342
|
+
try {
|
|
343
|
+
const data = JSON.parse(item.value);
|
|
344
|
+
const details = data.item;
|
|
345
|
+
if (!details?.id || !details?.region)
|
|
346
|
+
continue;
|
|
347
|
+
const licenses = details.licenseDetails ?? {};
|
|
348
|
+
return {
|
|
349
|
+
mri: details.id,
|
|
350
|
+
region: details.region,
|
|
351
|
+
userPartition: details.userPartition ?? '',
|
|
352
|
+
tenantPartition: details.partition ?? '',
|
|
353
|
+
licenses: {
|
|
354
|
+
isFreemium: licenses.isFreemium === true,
|
|
355
|
+
isTrial: licenses.isTrial === true,
|
|
356
|
+
isTeamsEnabled: licenses.isTeamsEnabled === true,
|
|
357
|
+
isCopilot: licenses.isCopilot === true,
|
|
358
|
+
isTranscriptEnabled: licenses.isTranscriptEnabled === true,
|
|
359
|
+
isFrontline: licenses.isFrontline === true,
|
|
360
|
+
},
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
catch {
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
return null;
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Extracts user MRI from the Substrate token's oid claim.
|
|
371
|
+
*/
|
|
372
|
+
function extractUserMriFromSubstrate(state) {
|
|
373
|
+
const substrateInfo = extractSubstrateToken(state);
|
|
374
|
+
if (!substrateInfo)
|
|
375
|
+
return null;
|
|
376
|
+
const payload = decodeJwtPayload(substrateInfo.token);
|
|
377
|
+
if (typeof payload?.oid === 'string') {
|
|
378
|
+
return `${MRI_ORGID_PREFIX}${payload.oid}`;
|
|
379
|
+
}
|
|
380
|
+
return null;
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Extracts authentication info needed for messaging API.
|
|
384
|
+
* Unlike other APIs, messaging uses cookies rather than localStorage tokens.
|
|
385
|
+
*/
|
|
386
|
+
export function extractMessageAuth(state) {
|
|
387
|
+
const sessionState = state ?? readSessionState();
|
|
388
|
+
if (!sessionState)
|
|
389
|
+
return null;
|
|
390
|
+
const cookies = sessionState.cookies ?? [];
|
|
391
|
+
const teamsCookies = cookies.filter(c => c.domain?.includes('teams.microsoft.com'));
|
|
392
|
+
// Extract the two required cookies
|
|
393
|
+
const skypeToken = teamsCookies.find(c => c.name === 'skypetoken_asm')?.value ?? null;
|
|
394
|
+
const rawAuthToken = teamsCookies.find(c => c.name === 'authtoken')?.value ?? null;
|
|
395
|
+
if (!skypeToken || !rawAuthToken)
|
|
396
|
+
return null;
|
|
397
|
+
// Decode authtoken (URL-encoded, may have 'Bearer=' prefix)
|
|
398
|
+
let authToken = decodeURIComponent(rawAuthToken);
|
|
399
|
+
if (authToken.startsWith('Bearer=')) {
|
|
400
|
+
authToken = authToken.substring(7);
|
|
401
|
+
}
|
|
402
|
+
// Extract userMri from skypeToken's skypeid claim, or fall back to authToken's oid
|
|
403
|
+
const userMri = extractMriFromSkypeToken(skypeToken)
|
|
404
|
+
?? extractMriFromAuthToken(authToken);
|
|
405
|
+
if (!userMri)
|
|
406
|
+
return null;
|
|
407
|
+
return { skypeToken, authToken, userMri };
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* Gets messaging token status for diagnostics.
|
|
411
|
+
* The skypetoken_asm cookie is a JWT with an exp claim.
|
|
412
|
+
*/
|
|
413
|
+
export function getMessageAuthStatus() {
|
|
414
|
+
const sessionState = readSessionState();
|
|
415
|
+
if (!sessionState) {
|
|
416
|
+
return { hasToken: false };
|
|
417
|
+
}
|
|
418
|
+
const cookies = sessionState.cookies ?? [];
|
|
419
|
+
const skypeToken = cookies.find(c => c.domain?.includes('teams.microsoft.com') && c.name === 'skypetoken_asm')?.value;
|
|
420
|
+
if (!skypeToken) {
|
|
421
|
+
return { hasToken: false };
|
|
422
|
+
}
|
|
423
|
+
const expiry = getJwtExpiry(skypeToken);
|
|
424
|
+
if (!expiry) {
|
|
425
|
+
// Token exists but can't parse expiry - assume valid
|
|
426
|
+
return { hasToken: true };
|
|
427
|
+
}
|
|
428
|
+
const now = Date.now();
|
|
429
|
+
const expiryMs = expiry.getTime();
|
|
430
|
+
return {
|
|
431
|
+
hasToken: expiryMs > now,
|
|
432
|
+
expiresAt: expiry.toISOString(),
|
|
433
|
+
minutesRemaining: Math.max(0, Math.round((expiryMs - now) / 1000 / 60)),
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
function extractMriFromSkypeToken(token) {
|
|
437
|
+
const payload = decodeJwtPayload(token);
|
|
438
|
+
if (typeof payload?.skypeid !== 'string')
|
|
439
|
+
return null;
|
|
440
|
+
// The skypeid claim may be 'orgid:guid' without the '8:' prefix
|
|
441
|
+
// Ensure we return the full MRI format '8:orgid:guid'
|
|
442
|
+
const skypeid = payload.skypeid;
|
|
443
|
+
if (skypeid.startsWith(MRI_TYPE_PREFIX)) {
|
|
444
|
+
return skypeid;
|
|
445
|
+
}
|
|
446
|
+
else if (skypeid.startsWith(ORGID_PREFIX)) {
|
|
447
|
+
return `${MRI_TYPE_PREFIX}${skypeid}`;
|
|
448
|
+
}
|
|
449
|
+
return skypeid;
|
|
450
|
+
}
|
|
451
|
+
function extractMriFromAuthToken(token) {
|
|
452
|
+
const payload = decodeJwtPayload(token);
|
|
453
|
+
return typeof payload?.oid === 'string' ? `${MRI_ORGID_PREFIX}${payload.oid}` : null;
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* Extracts the CSA token for the conversationFolders API.
|
|
457
|
+
* This searches all origins, not just teams.microsoft.com.
|
|
458
|
+
*/
|
|
459
|
+
export function extractCsaToken(state) {
|
|
460
|
+
const sessionState = state ?? readSessionState();
|
|
461
|
+
if (!sessionState)
|
|
462
|
+
return null;
|
|
463
|
+
for (const origin of sessionState.origins ?? []) {
|
|
464
|
+
for (const item of origin.localStorage ?? []) {
|
|
465
|
+
// Skip temporary entries, look for chatsvcagg tokens
|
|
466
|
+
if (item.name.startsWith('tmp.'))
|
|
467
|
+
continue;
|
|
468
|
+
if (!item.name.includes('chatsvcagg.teams.microsoft.com'))
|
|
469
|
+
continue;
|
|
470
|
+
try {
|
|
471
|
+
const entry = JSON.parse(item.value);
|
|
472
|
+
if (entry.secret)
|
|
473
|
+
return entry.secret;
|
|
474
|
+
}
|
|
475
|
+
catch {
|
|
476
|
+
// Ignore parse errors
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
return null;
|
|
481
|
+
}
|
|
482
|
+
// ============================================================================
|
|
483
|
+
// User Profile
|
|
484
|
+
// ============================================================================
|
|
485
|
+
/**
|
|
486
|
+
* Gets the current user's profile from cached JWT tokens.
|
|
487
|
+
*/
|
|
488
|
+
export function getUserProfile(state) {
|
|
489
|
+
const localStorage = getTeamsLocalStorage(state);
|
|
490
|
+
if (!localStorage)
|
|
491
|
+
return null;
|
|
492
|
+
for (const item of localStorage) {
|
|
493
|
+
try {
|
|
494
|
+
const entry = JSON.parse(item.value);
|
|
495
|
+
if (!isJwtToken(entry.secret))
|
|
496
|
+
continue;
|
|
497
|
+
const payload = decodeJwtPayload(entry.secret);
|
|
498
|
+
if (payload) {
|
|
499
|
+
const profile = parseJwtProfile(payload);
|
|
500
|
+
if (profile)
|
|
501
|
+
return profile;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
catch {
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
return null;
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* Gets user's display name from session state.
|
|
512
|
+
* Searches localStorage entries first, then falls back to JWT claims.
|
|
513
|
+
*/
|
|
514
|
+
export function getUserDisplayName(state) {
|
|
515
|
+
const localStorage = getTeamsLocalStorage(state);
|
|
516
|
+
if (!localStorage)
|
|
517
|
+
return null;
|
|
518
|
+
// First pass: look for explicit displayName in localStorage
|
|
519
|
+
for (const item of localStorage) {
|
|
520
|
+
// Quick filter before parsing
|
|
521
|
+
if (!item.value?.includes('displayName') && !item.value?.includes('givenName')) {
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
try {
|
|
525
|
+
const entry = JSON.parse(item.value);
|
|
526
|
+
if (entry.displayName)
|
|
527
|
+
return entry.displayName;
|
|
528
|
+
if (entry.name?.displayName)
|
|
529
|
+
return entry.name.displayName;
|
|
530
|
+
}
|
|
531
|
+
catch {
|
|
532
|
+
continue;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
// Fallback: extract from Teams token's name claim
|
|
536
|
+
const teamsToken = extractTeamsToken(state);
|
|
537
|
+
if (teamsToken) {
|
|
538
|
+
const payload = decodeJwtPayload(teamsToken.token);
|
|
539
|
+
if (typeof payload?.name === 'string')
|
|
540
|
+
return payload.name;
|
|
541
|
+
}
|
|
542
|
+
return null;
|
|
543
|
+
}
|
|
544
|
+
// ============================================================================
|
|
545
|
+
// Token Status Checks
|
|
546
|
+
// ============================================================================
|
|
547
|
+
/**
|
|
548
|
+
* Checks if tokens in session state are expired.
|
|
549
|
+
*/
|
|
550
|
+
export function areTokensExpired(state) {
|
|
551
|
+
const sessionState = state ?? readSessionState();
|
|
552
|
+
if (!sessionState)
|
|
553
|
+
return true;
|
|
554
|
+
const substrate = extractSubstrateToken(sessionState);
|
|
555
|
+
return !substrate || substrate.expiry.getTime() <= Date.now();
|
|
556
|
+
}
|
|
557
|
+
// Re-export clearTokenCache for convenience
|
|
558
|
+
export { clearTokenCache };
|
|
559
|
+
/**
|
|
560
|
+
* Extracts all configuration data from localStorage for debugging.
|
|
561
|
+
* This helps discover what config is available from different tenants.
|
|
562
|
+
*/
|
|
563
|
+
export function discoverConfig(state) {
|
|
564
|
+
const localStorage = getTeamsLocalStorage(state);
|
|
565
|
+
if (!localStorage)
|
|
566
|
+
return null;
|
|
567
|
+
const discoveryConfigs = {};
|
|
568
|
+
const configKeys = [];
|
|
569
|
+
const configContents = {};
|
|
570
|
+
const allUrls = [];
|
|
571
|
+
for (const item of localStorage) {
|
|
572
|
+
// Collect all DISCOVER-* keys
|
|
573
|
+
if (item.name.includes('DISCOVER')) {
|
|
574
|
+
try {
|
|
575
|
+
discoveryConfigs[item.name] = JSON.parse(item.value);
|
|
576
|
+
}
|
|
577
|
+
catch {
|
|
578
|
+
discoveryConfigs[item.name] = item.value;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
// Collect keys that look like config (not tokens/cache)
|
|
582
|
+
const isConfigKey = item.name.includes('config') ||
|
|
583
|
+
item.name.includes('CONFIG') ||
|
|
584
|
+
item.name.includes('settings') ||
|
|
585
|
+
item.name.includes('SETTINGS') ||
|
|
586
|
+
item.name.includes('environment') ||
|
|
587
|
+
item.name.includes('ENVIRONMENT') ||
|
|
588
|
+
item.name.includes('endpoint') ||
|
|
589
|
+
item.name.includes('ENDPOINT') ||
|
|
590
|
+
item.name.includes('DISCOVER') ||
|
|
591
|
+
item.name.includes('flags') ||
|
|
592
|
+
item.name.includes('FLAGS');
|
|
593
|
+
if (isConfigKey) {
|
|
594
|
+
configKeys.push(item.name);
|
|
595
|
+
// Also capture content for settings/flags keys (but not large token keys)
|
|
596
|
+
if (!item.name.includes('accesstoken') && !item.name.includes('DISCOVER')) {
|
|
597
|
+
try {
|
|
598
|
+
configContents[item.name] = JSON.parse(item.value);
|
|
599
|
+
}
|
|
600
|
+
catch {
|
|
601
|
+
// Only capture if it's short enough to be useful
|
|
602
|
+
if (item.value.length < MAX_DEBUG_CONFIG_VALUE_LENGTH) {
|
|
603
|
+
configContents[item.name] = item.value;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
// Extract URLs from any JSON values
|
|
609
|
+
try {
|
|
610
|
+
const extractUrls = (obj, depth = 0) => {
|
|
611
|
+
if (depth > 5)
|
|
612
|
+
return; // Prevent infinite recursion
|
|
613
|
+
if (typeof obj === 'string') {
|
|
614
|
+
// Match URLs
|
|
615
|
+
const urlMatch = obj.match(/https?:\/\/[^\s"'<>]+/g);
|
|
616
|
+
if (urlMatch)
|
|
617
|
+
allUrls.push(...urlMatch);
|
|
618
|
+
}
|
|
619
|
+
else if (typeof obj === 'object' && obj !== null) {
|
|
620
|
+
for (const value of Object.values(obj)) {
|
|
621
|
+
extractUrls(value, depth + 1);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
};
|
|
625
|
+
extractUrls(JSON.parse(item.value));
|
|
626
|
+
}
|
|
627
|
+
catch {
|
|
628
|
+
// Not JSON, skip
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
// Extract unique hosts from URLs
|
|
632
|
+
const uniqueHosts = [...new Set(allUrls
|
|
633
|
+
.map(url => {
|
|
634
|
+
try {
|
|
635
|
+
return new URL(url).host;
|
|
636
|
+
}
|
|
637
|
+
catch {
|
|
638
|
+
return null;
|
|
639
|
+
}
|
|
640
|
+
})
|
|
641
|
+
.filter((h) => h !== null))].sort();
|
|
642
|
+
// Try to find Teams base URL from discovery config
|
|
643
|
+
let teamsBaseUrl = null;
|
|
644
|
+
let substrateUrl = null;
|
|
645
|
+
for (const [key, value] of Object.entries(discoveryConfigs)) {
|
|
646
|
+
if (key.includes('DISCOVER-REGION-GTM') && typeof value === 'object' && value !== null) {
|
|
647
|
+
const item = value.item;
|
|
648
|
+
if (item?.chatServiceAfd) {
|
|
649
|
+
try {
|
|
650
|
+
const url = new URL(item.chatServiceAfd);
|
|
651
|
+
teamsBaseUrl = `${url.protocol}//${url.host}`;
|
|
652
|
+
}
|
|
653
|
+
catch {
|
|
654
|
+
// Invalid URL
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
// Look for Substrate URLs
|
|
660
|
+
for (const host of uniqueHosts) {
|
|
661
|
+
if (host.includes('substrate')) {
|
|
662
|
+
substrateUrl = `https://${host}`;
|
|
663
|
+
break;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
return {
|
|
667
|
+
discoveryConfigs,
|
|
668
|
+
configKeys,
|
|
669
|
+
configContents,
|
|
670
|
+
teamsBaseUrl,
|
|
671
|
+
substrateUrl,
|
|
672
|
+
uniqueHosts,
|
|
673
|
+
};
|
|
674
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token refresh via headless browser.
|
|
3
|
+
*
|
|
4
|
+
* Teams uses SPA OAuth2 which restricts refresh tokens to browser-based CORS
|
|
5
|
+
* requests. We open a headless browser with saved session state, let MSAL
|
|
6
|
+
* silently refresh tokens, then save the updated state. Seamless to the user.
|
|
7
|
+
*/
|
|
8
|
+
import { type Result } from '../types/result.js';
|
|
9
|
+
/** Result of a successful token refresh. */
|
|
10
|
+
export interface TokenRefreshResult {
|
|
11
|
+
/** New token expiry time. */
|
|
12
|
+
newExpiry: Date;
|
|
13
|
+
/** Previous expiry time (for comparison). */
|
|
14
|
+
previousExpiry: Date;
|
|
15
|
+
/** Minutes gained by refresh. */
|
|
16
|
+
minutesGained: number;
|
|
17
|
+
/** Whether a refresh was actually needed (token was close to expiry). */
|
|
18
|
+
refreshNeeded: boolean;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Refreshes tokens by opening a headless browser with saved session state.
|
|
22
|
+
* MSAL only refreshes tokens when an API call requires them, so we trigger
|
|
23
|
+
* a search via ensureAuthenticated to force token acquisition.
|
|
24
|
+
*/
|
|
25
|
+
export declare function refreshTokensViaBrowser(): Promise<Result<TokenRefreshResult>>;
|