m365connector 0.3.6 → 0.3.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -103
- package/package.json +4 -3
- package/src/browser-session.js +619 -0
- package/src/index.js +31 -14
- package/src/lib/content-reader.js +2746 -0
- package/src/lib/search-api.js +989 -0
- package/src/lib/vendor/jszip.min.js +13 -0
- package/src/token-providers.js +739 -0
- package/src/tools/search.js +17 -66
- package/src/ws-server.js +0 -203
|
@@ -0,0 +1,739 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
const SUBSTRATE_AUDIENCE = "https://substrate.office.com";
|
|
6
|
+
const GRAPH_AUDIENCE = "https://graph.microsoft.com";
|
|
7
|
+
const GRAPH_AUDIENCES = new Set([
|
|
8
|
+
GRAPH_AUDIENCE,
|
|
9
|
+
"00000003-0000-0000-c000-000000000000"
|
|
10
|
+
]);
|
|
11
|
+
|
|
12
|
+
const SUBSTRATE_ALIASES = new Map([
|
|
13
|
+
["https://substrate.office.com/search", SUBSTRATE_AUDIENCE],
|
|
14
|
+
["https://outlook.office365.com/search", SUBSTRATE_AUDIENCE],
|
|
15
|
+
["https://outlook.office365.com", SUBSTRATE_AUDIENCE]
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
const M365_SEARCH_URL = "https://m365.cloud.microsoft/search?q=m365connector-token-refresh";
|
|
19
|
+
const GRAPH_EXPLORER_PRIMARY_URL = "https://developer.microsoft.com/en-us/graph/graph-explorer";
|
|
20
|
+
const GRAPH_EXPLORER_FALLBACK_URL = "https://tryit.graphexplorer.microsoft.com/";
|
|
21
|
+
const GRAPH_SIGN_IN_HYDRATED_PAGE_TEXT_THRESHOLD = 3000;
|
|
22
|
+
const GRAPH_SIGN_IN_CONFIRM_COUNT = 3;
|
|
23
|
+
const TOKEN_EXPIRY_SKEW_SECONDS = 60;
|
|
24
|
+
const TOKEN_CACHE_VERSION = 1;
|
|
25
|
+
|
|
26
|
+
function expandHome(value) {
|
|
27
|
+
const text = String(value || "");
|
|
28
|
+
if (text === "~") {
|
|
29
|
+
return os.homedir();
|
|
30
|
+
}
|
|
31
|
+
if (text.startsWith("~/")) {
|
|
32
|
+
return path.join(os.homedir(), text.slice(2));
|
|
33
|
+
}
|
|
34
|
+
return text;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function defaultTokenCachePath() {
|
|
38
|
+
if (process.env.M365C_TOKEN_CACHE_PATH) {
|
|
39
|
+
return expandHome(process.env.M365C_TOKEN_CACHE_PATH);
|
|
40
|
+
}
|
|
41
|
+
return path.join(os.homedir(), ".m365connector", "token-cache.json");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function sleep(ms) {
|
|
45
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function nowSeconds() {
|
|
49
|
+
return Math.floor(Date.now() / 1000);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function normalizeAudience(aud) {
|
|
53
|
+
return String(aud || "").replace(/\/$/, "");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function parseJwtPayload(token) {
|
|
57
|
+
const parts = String(token || "").split(".");
|
|
58
|
+
if (parts.length < 2) {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
const raw = parts[1].replace(/-/g, "+").replace(/_/g, "/");
|
|
62
|
+
const padded = raw + "=".repeat((4 - (raw.length % 4 || 4)) % 4);
|
|
63
|
+
try {
|
|
64
|
+
return JSON.parse(Buffer.from(padded, "base64").toString("utf8"));
|
|
65
|
+
} catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function hasScope(payload, scope) {
|
|
71
|
+
if (!scope) {
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
const scopes = new Set(String(payload?.scp || "").split(" ").filter(Boolean));
|
|
75
|
+
if (scopes.has(scope)) {
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
if (scope.endsWith(".Read")) {
|
|
79
|
+
return scopes.has(scope.slice(0, -".Read".length) + ".ReadWrite");
|
|
80
|
+
}
|
|
81
|
+
if (scope.endsWith(".Read.All")) {
|
|
82
|
+
return scopes.has(scope.slice(0, -".Read.All".length) + ".ReadWrite.All");
|
|
83
|
+
}
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function tokenEntryFromJwt(token, canonicalAudience = null) {
|
|
88
|
+
const payload = parseJwtPayload(token);
|
|
89
|
+
if (!payload) {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
const aud = canonicalAudience || normalizeAudience(payload.aud);
|
|
93
|
+
const exp = Number(payload.exp || 0);
|
|
94
|
+
if (!aud || !payload.oid || !payload.tid || !Number.isFinite(exp)) {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
token,
|
|
99
|
+
aud,
|
|
100
|
+
oid: payload.oid,
|
|
101
|
+
tid: payload.tid,
|
|
102
|
+
upn: payload.upn || payload.preferred_username || "",
|
|
103
|
+
exp,
|
|
104
|
+
payload,
|
|
105
|
+
capturedAt: Date.now()
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export class ServerTokenManager {
|
|
110
|
+
constructor(options = {}) {
|
|
111
|
+
this.tokenEntries = new Map();
|
|
112
|
+
this.activeIdentity = null;
|
|
113
|
+
this.logger = options.logger || console;
|
|
114
|
+
this.cachePath = options.cachePath === false ? null : (options.cachePath || defaultTokenCachePath());
|
|
115
|
+
this.#loadFromDisk();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
setEntry(entry) {
|
|
119
|
+
if (!entry?.aud || !entry?.oid || !entry?.tid) {
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const key = `${entry.aud}|${entry.oid}|${entry.tid}`;
|
|
123
|
+
this.tokenEntries.set(key, entry);
|
|
124
|
+
this.activeIdentity = {
|
|
125
|
+
oid: entry.oid,
|
|
126
|
+
tid: entry.tid,
|
|
127
|
+
upn: entry.upn || ""
|
|
128
|
+
};
|
|
129
|
+
this.#saveToDisk();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
getTokenEntry(audience, minRemainingSeconds = TOKEN_EXPIRY_SKEW_SECONDS) {
|
|
133
|
+
const aud = normalizeAudience(audience);
|
|
134
|
+
if (!this.activeIdentity?.oid || !this.activeIdentity?.tid) {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
const entry = this.tokenEntries.get(`${aud}|${this.activeIdentity.oid}|${this.activeIdentity.tid}`);
|
|
138
|
+
if (!entry) {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
if (entry.exp - nowSeconds() <= minRemainingSeconds) {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
return entry;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async evictToken(audience, expectedToken = null) {
|
|
148
|
+
const aud = normalizeAudience(audience);
|
|
149
|
+
if (!this.activeIdentity?.oid || !this.activeIdentity?.tid) {
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const key = `${aud}|${this.activeIdentity.oid}|${this.activeIdentity.tid}`;
|
|
153
|
+
const entry = this.tokenEntries.get(key);
|
|
154
|
+
if (entry && (!expectedToken || entry.token === expectedToken)) {
|
|
155
|
+
this.tokenEntries.delete(key);
|
|
156
|
+
this.#saveToDisk();
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
#loadFromDisk() {
|
|
161
|
+
if (!this.cachePath) {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
let parsed;
|
|
165
|
+
try {
|
|
166
|
+
parsed = JSON.parse(fs.readFileSync(this.cachePath, "utf-8"));
|
|
167
|
+
} catch (err) {
|
|
168
|
+
if (err.code !== "ENOENT") {
|
|
169
|
+
this.logger.warn("[m365connector][token] failed to read token cache:", err.message);
|
|
170
|
+
}
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (parsed?.version !== TOKEN_CACHE_VERSION || !Array.isArray(parsed.entries)) {
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const now = nowSeconds();
|
|
179
|
+
for (const rawEntry of parsed.entries) {
|
|
180
|
+
const entry = tokenEntryFromJwt(rawEntry?.token, rawEntry?.aud);
|
|
181
|
+
if (!entry || entry.exp - now <= TOKEN_EXPIRY_SKEW_SECONDS) {
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
if (rawEntry?.requestUrl) {
|
|
185
|
+
entry.requestUrl = String(rawEntry.requestUrl);
|
|
186
|
+
}
|
|
187
|
+
const key = `${entry.aud}|${entry.oid}|${entry.tid}`;
|
|
188
|
+
this.tokenEntries.set(key, entry);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const active = parsed.activeIdentity;
|
|
192
|
+
if (active?.oid && active?.tid) {
|
|
193
|
+
this.activeIdentity = {
|
|
194
|
+
oid: String(active.oid),
|
|
195
|
+
tid: String(active.tid),
|
|
196
|
+
upn: String(active.upn || "")
|
|
197
|
+
};
|
|
198
|
+
if (!this.#hasEntryForIdentity(this.activeIdentity)) {
|
|
199
|
+
this.activeIdentity = null;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (!this.activeIdentity && this.tokenEntries.size) {
|
|
204
|
+
const first = this.tokenEntries.values().next().value;
|
|
205
|
+
this.activeIdentity = {
|
|
206
|
+
oid: first.oid,
|
|
207
|
+
tid: first.tid,
|
|
208
|
+
upn: first.upn || ""
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
#hasEntryForIdentity(identity) {
|
|
214
|
+
for (const entry of this.tokenEntries.values()) {
|
|
215
|
+
if (entry.oid === identity.oid && entry.tid === identity.tid) {
|
|
216
|
+
return true;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
#saveToDisk() {
|
|
223
|
+
if (!this.cachePath) {
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const now = nowSeconds();
|
|
228
|
+
const entries = [];
|
|
229
|
+
for (const [key, entry] of this.tokenEntries.entries()) {
|
|
230
|
+
if (entry.exp - now <= TOKEN_EXPIRY_SKEW_SECONDS) {
|
|
231
|
+
this.tokenEntries.delete(key);
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
entries.push({
|
|
235
|
+
aud: entry.aud,
|
|
236
|
+
token: entry.token,
|
|
237
|
+
requestUrl: entry.requestUrl || "",
|
|
238
|
+
capturedAt: entry.capturedAt || Date.now()
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const payload = {
|
|
243
|
+
version: TOKEN_CACHE_VERSION,
|
|
244
|
+
activeIdentity: this.activeIdentity,
|
|
245
|
+
entries
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
try {
|
|
249
|
+
fs.mkdirSync(path.dirname(this.cachePath), { recursive: true, mode: 0o700 });
|
|
250
|
+
const tmp = `${this.cachePath}.${process.pid}.${Date.now()}.tmp`;
|
|
251
|
+
fs.writeFileSync(tmp, JSON.stringify(payload, null, 2), { encoding: "utf-8", mode: 0o600 });
|
|
252
|
+
fs.chmodSync(tmp, 0o600);
|
|
253
|
+
fs.renameSync(tmp, this.cachePath);
|
|
254
|
+
fs.chmodSync(this.cachePath, 0o600);
|
|
255
|
+
} catch (err) {
|
|
256
|
+
this.logger.warn("[m365connector][token] failed to write token cache:", err.message);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export class SubstrateTokenProvider {
|
|
262
|
+
constructor(browserSession, tokenManager, options = {}) {
|
|
263
|
+
this.browserSession = browserSession;
|
|
264
|
+
this.tokenManager = tokenManager;
|
|
265
|
+
this.logger = options.logger || console;
|
|
266
|
+
this.refreshPromise = null;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async getTokenEntry() {
|
|
270
|
+
const cached = this.tokenManager.getTokenEntry(SUBSTRATE_AUDIENCE);
|
|
271
|
+
if (cached) {
|
|
272
|
+
return cached;
|
|
273
|
+
}
|
|
274
|
+
await this.refresh();
|
|
275
|
+
return this.tokenManager.getTokenEntry(SUBSTRATE_AUDIENCE);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async refresh() {
|
|
279
|
+
if (this.refreshPromise) {
|
|
280
|
+
return this.refreshPromise;
|
|
281
|
+
}
|
|
282
|
+
this.refreshPromise = this.#refreshInternal().finally(() => {
|
|
283
|
+
this.refreshPromise = null;
|
|
284
|
+
});
|
|
285
|
+
return this.refreshPromise;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async #refreshInternal() {
|
|
289
|
+
await this.browserSession.start();
|
|
290
|
+
const page = await this.browserSession.createPage("about:blank", { active: false });
|
|
291
|
+
let captured = null;
|
|
292
|
+
|
|
293
|
+
const onBearer = ({ token, url }) => {
|
|
294
|
+
const payload = parseJwtPayload(token);
|
|
295
|
+
if (!payload) {
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
const rawAud = normalizeAudience(payload.aud);
|
|
299
|
+
const aud = SUBSTRATE_ALIASES.get(rawAud) || rawAud;
|
|
300
|
+
if (aud !== SUBSTRATE_AUDIENCE) {
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
captured = tokenEntryFromJwt(token, SUBSTRATE_AUDIENCE);
|
|
304
|
+
if (captured) {
|
|
305
|
+
captured.requestUrl = url || "";
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
page.on("bearer", onBearer);
|
|
310
|
+
try {
|
|
311
|
+
await page.navigate(M365_SEARCH_URL, 30000).catch(() => {});
|
|
312
|
+
const deadline = Date.now() + 45000;
|
|
313
|
+
while (!captured && Date.now() < deadline) {
|
|
314
|
+
await sleep(500);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (!captured) {
|
|
318
|
+
throw new Error("Substrate token unavailable. Sign into Microsoft 365 in the opened Edge profile, then retry.");
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
this.tokenManager.setEntry(captured);
|
|
322
|
+
this.logger.info("[m365connector][token] captured Substrate token for", captured.upn || captured.oid);
|
|
323
|
+
return captured;
|
|
324
|
+
} finally {
|
|
325
|
+
page.off("bearer", onBearer);
|
|
326
|
+
if (captured) {
|
|
327
|
+
await page.close().catch(() => {});
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function extractGraphTokenFromPage() {
|
|
334
|
+
const text = document.body?.innerText || "";
|
|
335
|
+
const controlText = Array.from(
|
|
336
|
+
document.querySelectorAll("textarea,input,[contenteditable=true],pre,code")
|
|
337
|
+
)
|
|
338
|
+
.map((node) => node.value || node.textContent || "")
|
|
339
|
+
.filter(Boolean)
|
|
340
|
+
.join("\n");
|
|
341
|
+
const searchableText = `${text}\n${controlText}`;
|
|
342
|
+
const jwtMatches = [...searchableText.matchAll(/eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g)]
|
|
343
|
+
.map((match) => match[0]);
|
|
344
|
+
const uniqueTokens = [...new Set(jwtMatches)];
|
|
345
|
+
const graphAudiences = new Set([
|
|
346
|
+
"https://graph.microsoft.com",
|
|
347
|
+
"00000003-0000-0000-c000-000000000000"
|
|
348
|
+
]);
|
|
349
|
+
const now = Math.floor(Date.now() / 1000);
|
|
350
|
+
const candidates = [];
|
|
351
|
+
|
|
352
|
+
for (const jwt of uniqueTokens) {
|
|
353
|
+
const parts = jwt.split(".");
|
|
354
|
+
if (parts.length < 2) {
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
try {
|
|
358
|
+
const rawPayload = parts[1].replace(/-/g, "+").replace(/_/g, "/");
|
|
359
|
+
const pad = rawPayload.length % 4;
|
|
360
|
+
const paddedPayload = pad === 0 ? rawPayload : rawPayload + "=".repeat(4 - pad);
|
|
361
|
+
const payload = JSON.parse(atob(paddedPayload));
|
|
362
|
+
const exp = Number(payload.exp || 0);
|
|
363
|
+
if (graphAudiences.has(payload.aud) && Number.isFinite(exp) && exp > now) {
|
|
364
|
+
candidates.push({ jwt, aud: payload.aud, exp });
|
|
365
|
+
}
|
|
366
|
+
} catch {
|
|
367
|
+
// Ignore non-token examples or stale rendered snippets.
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
candidates.sort((a, b) => b.exp - a.exp);
|
|
372
|
+
const selected = candidates[0] || null;
|
|
373
|
+
return {
|
|
374
|
+
token: selected?.jwt || null,
|
|
375
|
+
jwtMatchCount: uniqueTokens.length,
|
|
376
|
+
validGraphTokenCount: candidates.length,
|
|
377
|
+
selectedAud: selected?.aud || null,
|
|
378
|
+
selectedExp: selected?.exp || null,
|
|
379
|
+
pageTextLength: text.length,
|
|
380
|
+
controlTextLength: controlText.length,
|
|
381
|
+
url: location.href,
|
|
382
|
+
hasJwtMatch: uniqueTokens.length > 0,
|
|
383
|
+
hasSignInButton: Boolean(document.querySelector('button[aria-label="Sign in"]')),
|
|
384
|
+
hasAccessTokenSignInMessage: text.toLowerCase().includes("to view your access token")
|
|
385
|
+
&& text.toLowerCase().includes("sign in")
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function activateAccessTokenTab() {
|
|
390
|
+
const tabs = document.querySelectorAll("[role=tab]");
|
|
391
|
+
for (const tab of tabs) {
|
|
392
|
+
const label = `${tab.textContent || ""} ${tab.getAttribute("aria-label") || ""}`.trim();
|
|
393
|
+
if (label.toLowerCase().includes("access token")) {
|
|
394
|
+
tab.click();
|
|
395
|
+
return true;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
return false;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function clickGraphExplorerSignIn() {
|
|
402
|
+
const direct = document.querySelector('button[aria-label="Sign in"]');
|
|
403
|
+
if (direct) {
|
|
404
|
+
const rect = direct.getBoundingClientRect();
|
|
405
|
+
direct.click();
|
|
406
|
+
return {
|
|
407
|
+
clicked: true,
|
|
408
|
+
strategy: "aria-label",
|
|
409
|
+
rect: { x: rect.x, y: rect.y, w: rect.width, h: rect.height }
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const buttons = Array.from(document.querySelectorAll("button"));
|
|
414
|
+
for (const button of buttons) {
|
|
415
|
+
const label = `${button.textContent || ""} ${button.getAttribute("aria-label") || ""} ${button.title || ""}`.toLowerCase();
|
|
416
|
+
if (label.includes("sign in") || label.includes("sign-in") || label.includes("account")) {
|
|
417
|
+
const rect = button.getBoundingClientRect();
|
|
418
|
+
button.click();
|
|
419
|
+
return {
|
|
420
|
+
clicked: true,
|
|
421
|
+
strategy: "button-label",
|
|
422
|
+
label: label.slice(0, 120),
|
|
423
|
+
rect: { x: rect.x, y: rect.y, w: rect.width, h: rect.height }
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
return { clicked: false };
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function pickGraphTokenExtraction(results) {
|
|
432
|
+
const infos = (results || [])
|
|
433
|
+
.map((entry) => ({ frameId: entry?.frameId || null, ...(entry?.result || {}) }))
|
|
434
|
+
.filter((info) => info && typeof info === "object");
|
|
435
|
+
if (!infos.length) {
|
|
436
|
+
return null;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const withToken = infos.find((info) => info.token);
|
|
440
|
+
if (withToken) {
|
|
441
|
+
return withToken;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
infos.sort((a, b) => {
|
|
445
|
+
const aScore = Number(a.validGraphTokenCount || 0) * 100000
|
|
446
|
+
+ Number(a.jwtMatchCount || 0) * 1000
|
|
447
|
+
+ Number(a.pageTextLength || 0)
|
|
448
|
+
+ Number(a.controlTextLength || 0);
|
|
449
|
+
const bScore = Number(b.validGraphTokenCount || 0) * 100000
|
|
450
|
+
+ Number(b.jwtMatchCount || 0) * 1000
|
|
451
|
+
+ Number(b.pageTextLength || 0)
|
|
452
|
+
+ Number(b.controlTextLength || 0);
|
|
453
|
+
return bScore - aScore;
|
|
454
|
+
});
|
|
455
|
+
return infos[0];
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function clickSignedInMicrosoftAccount() {
|
|
459
|
+
const text = document.body?.innerText || "";
|
|
460
|
+
const rows = Array.from(document.querySelectorAll('[role="button"], button, div, a'));
|
|
461
|
+
const scored = [];
|
|
462
|
+
|
|
463
|
+
for (const row of rows) {
|
|
464
|
+
const label = `${row.textContent || ""} ${row.getAttribute?.("aria-label") || ""}`.replace(/\s+/g, " ").trim();
|
|
465
|
+
if (!label) {
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
const lower = label.toLowerCase();
|
|
469
|
+
const rect = row.getBoundingClientRect();
|
|
470
|
+
if (rect.width <= 0 || rect.height <= 0) {
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
let score = 0;
|
|
474
|
+
if (lower.includes("signed in")) score += 10;
|
|
475
|
+
if (lower.includes("@")) score += 5;
|
|
476
|
+
if (lower.includes("jietong@microsoft.com")) score += 20;
|
|
477
|
+
if (lower.includes("use another account")) score -= 20;
|
|
478
|
+
if (lower.includes("pick an account")) score -= 5;
|
|
479
|
+
if (score > 0) {
|
|
480
|
+
scored.push({ row, label, score, rect });
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
scored.sort((a, b) => b.score - a.score);
|
|
485
|
+
const selected = scored[0];
|
|
486
|
+
if (!selected) {
|
|
487
|
+
return { clicked: false, textLength: text.length };
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
selected.row.click();
|
|
491
|
+
return {
|
|
492
|
+
clicked: true,
|
|
493
|
+
label: selected.label.slice(0, 160),
|
|
494
|
+
score: selected.score,
|
|
495
|
+
rect: {
|
|
496
|
+
x: selected.rect.x,
|
|
497
|
+
y: selected.rect.y,
|
|
498
|
+
w: selected.rect.width,
|
|
499
|
+
h: selected.rect.height
|
|
500
|
+
}
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
export class GraphTokenProvider {
|
|
505
|
+
constructor(browserSession, options = {}) {
|
|
506
|
+
this.browserSession = browserSession;
|
|
507
|
+
this.logger = options.logger || console;
|
|
508
|
+
this.tokenManager = options.tokenManager || null;
|
|
509
|
+
this.token = null;
|
|
510
|
+
this.tokenPayload = null;
|
|
511
|
+
this.tokenExp = 0;
|
|
512
|
+
this.page = null;
|
|
513
|
+
this.refreshPromise = null;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
async getToken(requiredScope = null) {
|
|
517
|
+
const cached = this.tokenManager?.getTokenEntry(GRAPH_AUDIENCE, 600);
|
|
518
|
+
if (cached && hasScope(cached.payload, requiredScope)) {
|
|
519
|
+
this.#useTokenEntry(cached);
|
|
520
|
+
return cached.token;
|
|
521
|
+
}
|
|
522
|
+
if (this.#isTokenValid(600) && hasScope(this.tokenPayload, requiredScope)) {
|
|
523
|
+
return this.token;
|
|
524
|
+
}
|
|
525
|
+
await this.refresh();
|
|
526
|
+
if (this.#isTokenValid(60) && hasScope(this.tokenPayload, requiredScope)) {
|
|
527
|
+
return this.token;
|
|
528
|
+
}
|
|
529
|
+
return null;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
async refresh() {
|
|
533
|
+
if (this.refreshPromise) {
|
|
534
|
+
return this.refreshPromise;
|
|
535
|
+
}
|
|
536
|
+
this.refreshPromise = this.#refreshInternal().finally(() => {
|
|
537
|
+
this.refreshPromise = null;
|
|
538
|
+
});
|
|
539
|
+
return this.refreshPromise;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
async #refreshInternal() {
|
|
543
|
+
await this.browserSession.start();
|
|
544
|
+
const candidates = [
|
|
545
|
+
this.page?.url || GRAPH_EXPLORER_PRIMARY_URL,
|
|
546
|
+
GRAPH_EXPLORER_PRIMARY_URL,
|
|
547
|
+
GRAPH_EXPLORER_FALLBACK_URL
|
|
548
|
+
];
|
|
549
|
+
const urls = [...new Set(candidates.filter(Boolean))];
|
|
550
|
+
|
|
551
|
+
for (const url of urls) {
|
|
552
|
+
const token = await this.#tryGraphExplorerUrl(url);
|
|
553
|
+
if (token) {
|
|
554
|
+
return token;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
return null;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
async #tryGraphExplorerUrl(url) {
|
|
562
|
+
if (!this.page || this.page.closed) {
|
|
563
|
+
this.page = await this.browserSession.createPage("about:blank", { active: false });
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
let headerToken = null;
|
|
567
|
+
const onBearer = ({ token }) => {
|
|
568
|
+
const payload = parseJwtPayload(token);
|
|
569
|
+
if (payload && GRAPH_AUDIENCES.has(payload.aud) && Number(payload.exp || 0) > nowSeconds()) {
|
|
570
|
+
headerToken = token;
|
|
571
|
+
}
|
|
572
|
+
};
|
|
573
|
+
this.page.on("bearer", onBearer);
|
|
574
|
+
|
|
575
|
+
try {
|
|
576
|
+
await this.page.navigate(url, 30000).catch(() => {});
|
|
577
|
+
const deadline = Date.now() + 120000;
|
|
578
|
+
let lastInfo = null;
|
|
579
|
+
let signInClicked = false;
|
|
580
|
+
let signInDetectCount = 0;
|
|
581
|
+
while (Date.now() < deadline) {
|
|
582
|
+
if (headerToken && this.#acceptToken(headerToken)) {
|
|
583
|
+
return headerToken;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
const executions = await this.page.evaluateFunction(
|
|
587
|
+
extractGraphTokenFromPage,
|
|
588
|
+
[],
|
|
589
|
+
{ allFrames: true }
|
|
590
|
+
).catch(() => []);
|
|
591
|
+
const info = pickGraphTokenExtraction(executions);
|
|
592
|
+
if (info && typeof info === "object") {
|
|
593
|
+
lastInfo = info;
|
|
594
|
+
if (info.token && this.#acceptToken(info.token)) {
|
|
595
|
+
this.logger.info("[m365connector][graph] captured Graph token from Graph Explorer page text");
|
|
596
|
+
return info.token;
|
|
597
|
+
}
|
|
598
|
+
if (!info.token) {
|
|
599
|
+
await this.page.evaluateFunction(activateAccessTokenTab, [], { allFrames: true }).catch(() => {});
|
|
600
|
+
}
|
|
601
|
+
if (info.pageTextLength >= GRAPH_SIGN_IN_HYDRATED_PAGE_TEXT_THRESHOLD && info.hasSignInButton) {
|
|
602
|
+
signInDetectCount += 1;
|
|
603
|
+
} else if (info.pageTextLength >= GRAPH_SIGN_IN_HYDRATED_PAGE_TEXT_THRESHOLD) {
|
|
604
|
+
signInDetectCount = 0;
|
|
605
|
+
}
|
|
606
|
+
const signInReady = info.hasSignInButton
|
|
607
|
+
&& (
|
|
608
|
+
signInDetectCount >= GRAPH_SIGN_IN_CONFIRM_COUNT
|
|
609
|
+
|| info.hasAccessTokenSignInMessage
|
|
610
|
+
);
|
|
611
|
+
if (signInReady && !signInClicked) {
|
|
612
|
+
signInClicked = true;
|
|
613
|
+
const clickResults = await this.page.evaluateFunction(
|
|
614
|
+
clickGraphExplorerSignIn,
|
|
615
|
+
[],
|
|
616
|
+
{ allFrames: true }
|
|
617
|
+
).catch(() => []);
|
|
618
|
+
const clickResult = (clickResults || []).find((entry) => entry?.result?.clicked)
|
|
619
|
+
|| clickResults?.[0];
|
|
620
|
+
const rect = clickResult?.result?.rect;
|
|
621
|
+
if (rect && rect.w > 0 && rect.h > 0) {
|
|
622
|
+
await this.page.clickAt(rect.x + rect.w / 2, rect.y + rect.h / 2).catch(() => {});
|
|
623
|
+
}
|
|
624
|
+
this.logger.warn(
|
|
625
|
+
"[m365connector][graph] clicked Graph Explorer sign-in:",
|
|
626
|
+
JSON.stringify(clickResult?.result || {})
|
|
627
|
+
);
|
|
628
|
+
await this.#completeLoginPopup(deadline);
|
|
629
|
+
await sleep(3000);
|
|
630
|
+
continue;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
await sleep(1200);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
this.logger.warn(
|
|
638
|
+
"[m365connector][graph] no token from Graph Explorer",
|
|
639
|
+
url,
|
|
640
|
+
lastInfo ? `jwtMatches=${lastInfo.jwtMatchCount} valid=${lastInfo.validGraphTokenCount}` : ""
|
|
641
|
+
);
|
|
642
|
+
return null;
|
|
643
|
+
} finally {
|
|
644
|
+
this.page.off("bearer", onBearer);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
async #completeLoginPopup(deadline) {
|
|
649
|
+
let accountClicked = false;
|
|
650
|
+
while (Date.now() < deadline) {
|
|
651
|
+
const loginPage = await this.browserSession.findPage((target) => {
|
|
652
|
+
try {
|
|
653
|
+
const parsed = new URL(target.url || "");
|
|
654
|
+
return parsed.hostname === "login.microsoftonline.com";
|
|
655
|
+
} catch {
|
|
656
|
+
return false;
|
|
657
|
+
}
|
|
658
|
+
}).catch(() => null);
|
|
659
|
+
|
|
660
|
+
if (!loginPage) {
|
|
661
|
+
if (accountClicked) {
|
|
662
|
+
return true;
|
|
663
|
+
}
|
|
664
|
+
await sleep(500);
|
|
665
|
+
continue;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
const [result] = await loginPage.evaluateFunction(clickSignedInMicrosoftAccount).catch(() => []);
|
|
669
|
+
const info = result?.result || {};
|
|
670
|
+
if (info.clicked) {
|
|
671
|
+
accountClicked = true;
|
|
672
|
+
const rect = info.rect;
|
|
673
|
+
if (rect && rect.w > 0 && rect.h > 0) {
|
|
674
|
+
await loginPage.clickAt(rect.x + rect.w / 2, rect.y + rect.h / 2).catch(() => {});
|
|
675
|
+
}
|
|
676
|
+
this.logger.warn("[m365connector][graph] clicked Microsoft login account:", JSON.stringify(info));
|
|
677
|
+
await sleep(3000);
|
|
678
|
+
} else {
|
|
679
|
+
await sleep(1000);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
const stillLogin = await this.browserSession.findPage((target) => {
|
|
683
|
+
try {
|
|
684
|
+
return new URL(target.url || "").hostname === "login.microsoftonline.com";
|
|
685
|
+
} catch {
|
|
686
|
+
return false;
|
|
687
|
+
}
|
|
688
|
+
}).catch(() => null);
|
|
689
|
+
if (!stillLogin && accountClicked) {
|
|
690
|
+
return true;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
return accountClicked;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
#acceptToken(token) {
|
|
697
|
+
const payload = parseJwtPayload(token);
|
|
698
|
+
const exp = Number(payload?.exp || 0);
|
|
699
|
+
if (!payload || !GRAPH_AUDIENCES.has(payload.aud) || !Number.isFinite(exp) || exp <= nowSeconds()) {
|
|
700
|
+
return false;
|
|
701
|
+
}
|
|
702
|
+
this.token = token;
|
|
703
|
+
this.tokenPayload = payload;
|
|
704
|
+
this.tokenExp = exp;
|
|
705
|
+
const entry = tokenEntryFromJwt(token, GRAPH_AUDIENCE);
|
|
706
|
+
if (entry) {
|
|
707
|
+
this.tokenManager?.setEntry(entry);
|
|
708
|
+
}
|
|
709
|
+
return true;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
#useTokenEntry(entry) {
|
|
713
|
+
this.token = entry.token;
|
|
714
|
+
this.tokenPayload = entry.payload;
|
|
715
|
+
this.tokenExp = entry.exp;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
#isTokenValid(minRemainingSeconds) {
|
|
719
|
+
return Boolean(this.token && this.tokenExp - nowSeconds() > minRemainingSeconds);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
async getStatus() {
|
|
723
|
+
const remainingSeconds = this.tokenExp ? this.tokenExp - nowSeconds() : null;
|
|
724
|
+
return {
|
|
725
|
+
enabled: true,
|
|
726
|
+
permissionGranted: true,
|
|
727
|
+
state: remainingSeconds && remainingSeconds > 0 ? (remainingSeconds < 600 ? "expiring" : "valid") : "missing",
|
|
728
|
+
exp: this.tokenExp || null,
|
|
729
|
+
remainingSeconds,
|
|
730
|
+
scopes: this.tokenPayload?.scp || "",
|
|
731
|
+
tabId: this.page?.id || null
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
export const TOKEN_AUDIENCES = {
|
|
737
|
+
SUBSTRATE: SUBSTRATE_AUDIENCE,
|
|
738
|
+
GRAPH: GRAPH_AUDIENCE
|
|
739
|
+
};
|