brightspace-mcp-server 1.6.2 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -7
- package/build/api/client.d.ts +5 -3
- package/build/api/client.d.ts.map +1 -1
- package/build/api/client.js +71 -79
- package/build/api/client.js.map +1 -1
- package/build/api/errors.d.ts +4 -0
- package/build/api/errors.d.ts.map +1 -1
- package/build/api/errors.js +7 -0
- package/build/api/errors.js.map +1 -1
- package/build/auth/auth-cooldown.d.ts +16 -0
- package/build/auth/auth-cooldown.d.ts.map +1 -0
- package/build/auth/auth-cooldown.js +47 -0
- package/build/auth/auth-cooldown.js.map +1 -0
- package/build/auth/auth-lock.d.ts +7 -0
- package/build/auth/auth-lock.d.ts.map +1 -0
- package/build/auth/auth-lock.js +114 -0
- package/build/auth/auth-lock.js.map +1 -0
- package/build/auth/auth-runner.d.ts +17 -7
- package/build/auth/auth-runner.d.ts.map +1 -1
- package/build/auth/auth-runner.js +122 -26
- package/build/auth/auth-runner.js.map +1 -1
- package/build/auth/browser-auth.d.ts +19 -67
- package/build/auth/browser-auth.d.ts.map +1 -1
- package/build/auth/browser-auth.js +258 -574
- package/build/auth/browser-auth.js.map +1 -1
- package/build/auth/browser-state-store.d.ts +19 -0
- package/build/auth/browser-state-store.d.ts.map +1 -0
- package/build/auth/browser-state-store.js +101 -0
- package/build/auth/browser-state-store.js.map +1 -0
- package/build/auth/credential-store.d.ts +29 -0
- package/build/auth/credential-store.d.ts.map +1 -0
- package/build/auth/credential-store.js +233 -0
- package/build/auth/credential-store.js.map +1 -0
- package/build/auth/encrypted-store.d.ts +25 -0
- package/build/auth/encrypted-store.d.ts.map +1 -0
- package/build/auth/encrypted-store.js +61 -0
- package/build/auth/encrypted-store.js.map +1 -0
- package/build/auth/legacy-profile.d.ts +3 -0
- package/build/auth/legacy-profile.d.ts.map +1 -0
- package/build/auth/legacy-profile.js +41 -0
- package/build/auth/legacy-profile.js.map +1 -0
- package/build/auth/legacy-state.d.ts +14 -0
- package/build/auth/legacy-state.d.ts.map +1 -0
- package/build/auth/legacy-state.js +107 -0
- package/build/auth/legacy-state.js.map +1 -0
- package/build/auth/purdue-sso.d.ts +18 -36
- package/build/auth/purdue-sso.d.ts.map +1 -1
- package/build/auth/purdue-sso.js +165 -192
- package/build/auth/purdue-sso.js.map +1 -1
- package/build/auth/session-store.d.ts +16 -38
- package/build/auth/session-store.d.ts.map +1 -1
- package/build/auth/session-store.js +157 -152
- package/build/auth/session-store.js.map +1 -1
- package/build/auth/sso-flow.d.ts +14 -3
- package/build/auth/sso-flow.d.ts.map +1 -1
- package/build/auth/sso-flow.js +16 -1
- package/build/auth/sso-flow.js.map +1 -1
- package/build/auth/suny-sso.d.ts +3 -2
- package/build/auth/suny-sso.d.ts.map +1 -1
- package/build/auth/suny-sso.js +15 -24
- package/build/auth/suny-sso.js.map +1 -1
- package/build/auth/token-manager.d.ts +10 -3
- package/build/auth/token-manager.d.ts.map +1 -1
- package/build/auth/token-manager.js +62 -26
- package/build/auth/token-manager.js.map +1 -1
- package/build/auth/token-mint.d.ts +2 -2
- package/build/auth/token-mint.d.ts.map +1 -1
- package/build/auth/token-mint.js +31 -9
- package/build/auth/token-mint.js.map +1 -1
- package/build/auth-cli.d.ts +1 -7
- package/build/auth-cli.d.ts.map +1 -1
- package/build/auth-cli.js +29 -73
- package/build/auth-cli.js.map +1 -1
- package/build/index.js +8 -4
- package/build/index.js.map +1 -1
- package/build/setup.js +14 -8
- package/build/setup.js.map +1 -1
- package/build/types/index.d.ts +6 -0
- package/build/types/index.d.ts.map +1 -1
- package/build/utils/config-store.d.ts +1 -0
- package/build/utils/config-store.d.ts.map +1 -1
- package/build/utils/config-store.js +3 -0
- package/build/utils/config-store.js.map +1 -1
- package/build/utils/config.d.ts +2 -1
- package/build/utils/config.d.ts.map +1 -1
- package/build/utils/config.js +30 -10
- package/build/utils/config.js.map +1 -1
- package/build/utils/secure-config.d.ts +6 -0
- package/build/utils/secure-config.d.ts.map +1 -0
- package/build/utils/secure-config.js +57 -0
- package/build/utils/secure-config.js.map +1 -0
- package/package.json +4 -2
|
@@ -1,314 +1,151 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Brightspace MCP Server
|
|
3
3
|
* Copyright (c) 2026 Rohan Muppa. All rights reserved.
|
|
4
|
-
* Licensed under MIT
|
|
4
|
+
* Licensed under MIT: see LICENSE file for details.
|
|
5
5
|
*/
|
|
6
6
|
import * as path from "node:path";
|
|
7
|
-
import
|
|
8
|
-
import * as os from "node:os";
|
|
7
|
+
import { readFileSync, accessSync } from "node:fs";
|
|
9
8
|
import { BrowserAuthError } from "../utils/errors.js";
|
|
10
9
|
import { log } from "../utils/logger.js";
|
|
11
|
-
import { createSSOFlow } from "./sso-flow.js";
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
* get clicked. A URL check cannot see any of that, so the wait is a poll on a
|
|
17
|
-
* positive session check instead.
|
|
18
|
-
*/
|
|
10
|
+
import { createSSOFlow, UnsupportedAuthenticationError, MfaApprovalError } from "./sso-flow.js";
|
|
11
|
+
import { BrowserStateStore } from "./browser-state-store.js";
|
|
12
|
+
import { acquireProcessLock } from "./auth-lock.js";
|
|
13
|
+
import { AuthCooldown } from "./auth-cooldown.js";
|
|
14
|
+
import { mintAccessToken } from "./token-mint.js";
|
|
19
15
|
const SILENT_SSO_TIMEOUT_MS = 30000;
|
|
20
16
|
const SILENT_SSO_POLL_MS = 1000;
|
|
17
|
+
const INITIAL_NAVIGATION_TIMEOUT_MS = 60000;
|
|
21
18
|
const SILENT_SSO = {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
19
|
+
emailFields: ["input[type=email]", "input[name=loginfmt]"],
|
|
20
|
+
credentialFields: ['input#username', 'input#userName', 'input[type="password"]'],
|
|
21
|
+
mfaChallenges: ["#idRichContext_DisplaySign", "#idDiv_SAOTCAS_Title", "#idDiv_SAOTCC_Title"],
|
|
25
22
|
campusSaml: 'a[href*="/d2l/lp/auth/saml/initiate-login"]',
|
|
26
|
-
/** Markers unique to the "Stay signed in?" page. */
|
|
27
23
|
kmsiCheckbox: "#KmsiCheckboxField",
|
|
28
24
|
kmsiTitle: "Stay signed in?",
|
|
29
25
|
kmsiSubmit: "#idSIButton9",
|
|
30
26
|
};
|
|
27
|
+
export class BrowserAuthTransportError extends Error {
|
|
28
|
+
code = "AUTH_TRANSPORT";
|
|
29
|
+
constructor(message, options) {
|
|
30
|
+
super(message, options);
|
|
31
|
+
this.name = "BrowserAuthTransportError";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
31
34
|
export class BrowserAuth {
|
|
32
35
|
config;
|
|
33
36
|
ssoFlow;
|
|
37
|
+
stateStore;
|
|
38
|
+
cooldown;
|
|
34
39
|
constructor(config) {
|
|
35
40
|
this.config = config;
|
|
36
41
|
this.ssoFlow = createSSOFlow(config);
|
|
42
|
+
this.stateStore = new BrowserStateStore(config.sessionDir);
|
|
43
|
+
this.cooldown = new AuthCooldown(config.sessionDir);
|
|
37
44
|
}
|
|
38
|
-
/**
|
|
39
|
-
* Detect if running inside WSL (Windows Subsystem for Linux) or Docker.
|
|
40
|
-
* These environments require --no-sandbox for Chromium to launch.
|
|
41
|
-
*/
|
|
42
45
|
static isWSLOrDocker() {
|
|
43
46
|
try {
|
|
44
|
-
|
|
45
|
-
const procVersion = require("node:fs").readFileSync("/proc/version", "utf-8");
|
|
46
|
-
if (/microsoft|wsl/i.test(procVersion))
|
|
47
|
+
if (/microsoft|wsl/i.test(readFileSync("/proc/version", "utf8")))
|
|
47
48
|
return true;
|
|
48
49
|
}
|
|
49
|
-
catch {
|
|
50
|
-
// Not Linux or /proc not available
|
|
51
|
-
}
|
|
50
|
+
catch { /* Not WSL. */ }
|
|
52
51
|
try {
|
|
53
|
-
|
|
54
|
-
require("node:fs").accessSync("/.dockerenv");
|
|
52
|
+
accessSync("/.dockerenv");
|
|
55
53
|
return true;
|
|
56
54
|
}
|
|
57
|
-
catch {
|
|
58
|
-
// Not Docker
|
|
59
|
-
}
|
|
60
|
-
try {
|
|
61
|
-
const cgroup = require("node:fs").readFileSync("/proc/1/cgroup", "utf-8");
|
|
62
|
-
if (cgroup.includes("docker") || cgroup.includes("containerd"))
|
|
63
|
-
return true;
|
|
64
|
-
}
|
|
65
|
-
catch {
|
|
66
|
-
// Not in a container
|
|
67
|
-
}
|
|
68
|
-
return false;
|
|
69
|
-
}
|
|
70
|
-
/**
|
|
71
|
-
* Build Chromium launch args based on the current platform and environment.
|
|
72
|
-
*/
|
|
73
|
-
static buildChromiumArgs() {
|
|
74
|
-
const args = ["--disable-blink-features=AutomationControlled"];
|
|
75
|
-
if (process.platform === "win32") {
|
|
76
|
-
args.push("--disable-gpu");
|
|
77
|
-
}
|
|
78
|
-
// On macOS, NSPersistentUIRestorer is disabled via `defaults write` in
|
|
79
|
-
// applyMacOSCrashGuard() — see issue #10. Passing "-ApplePersistenceIgnoreState YES"
|
|
80
|
-
// as argv doesn't work here because Playwright's launchPersistentContext rejects
|
|
81
|
-
// non-flag positional arguments.
|
|
82
|
-
if (BrowserAuth.isWSLOrDocker()) {
|
|
83
|
-
args.push("--no-sandbox", "--disable-setuid-sandbox");
|
|
84
|
-
log("INFO", "Detected WSL/Docker environment — launching Chromium with --no-sandbox");
|
|
85
|
-
}
|
|
86
|
-
return args;
|
|
87
|
-
}
|
|
88
|
-
/**
|
|
89
|
-
* Prevent Chrome for Testing from SIGTRAP'ing on launch on macOS (issue #10).
|
|
90
|
-
*
|
|
91
|
-
* Three layers, all idempotent and cheap:
|
|
92
|
-
* 1. ApplePersistenceIgnoreState — disables NSPersistentUIRestorer's crash-prompt
|
|
93
|
-
* modal, which Chrome for Testing's AppKit bridge cannot handle.
|
|
94
|
-
* 2. IIO_LaunchInfo=0 — resets LaunchServices' per-app crash counter so macOS
|
|
95
|
-
* stops triggering the recovery pathway in the first place.
|
|
96
|
-
* 3. Nuke the Cocoa saved-application-state bundle — if it exists, AppKit tries
|
|
97
|
-
* to replay window state during launch and can crash the browser process.
|
|
98
|
-
*
|
|
99
|
-
* Runs before every launch. None of these are destructive to user data; Chrome
|
|
100
|
-
* for Testing is a disposable test profile.
|
|
101
|
-
*/
|
|
102
|
-
static async applyMacOSCrashGuard() {
|
|
103
|
-
if (process.platform !== "darwin")
|
|
104
|
-
return;
|
|
105
|
-
const { execFile } = await import("node:child_process");
|
|
106
|
-
const { promisify } = await import("node:util");
|
|
107
|
-
const execFileAsync = promisify(execFile);
|
|
108
|
-
try {
|
|
109
|
-
await execFileAsync("defaults", [
|
|
110
|
-
"write",
|
|
111
|
-
"com.google.chrome.for.testing",
|
|
112
|
-
"ApplePersistenceIgnoreState",
|
|
113
|
-
"-bool",
|
|
114
|
-
"yes",
|
|
115
|
-
]);
|
|
116
|
-
}
|
|
117
|
-
catch {
|
|
118
|
-
// Non-fatal
|
|
119
|
-
}
|
|
120
|
-
try {
|
|
121
|
-
await execFileAsync("defaults", [
|
|
122
|
-
"write",
|
|
123
|
-
"com.google.chrome.for.testing",
|
|
124
|
-
"IIO_LaunchInfo",
|
|
125
|
-
"-int",
|
|
126
|
-
"0",
|
|
127
|
-
]);
|
|
128
|
-
}
|
|
129
|
-
catch {
|
|
130
|
-
// Non-fatal
|
|
131
|
-
}
|
|
55
|
+
catch { /* Not Docker. */ }
|
|
132
56
|
try {
|
|
133
|
-
|
|
134
|
-
await fs.rm(savedState, { recursive: true, force: true });
|
|
57
|
+
return /docker|containerd/.test(readFileSync("/proc/1/cgroup", "utf8"));
|
|
135
58
|
}
|
|
136
59
|
catch {
|
|
137
|
-
|
|
60
|
+
return false;
|
|
138
61
|
}
|
|
139
|
-
log("DEBUG", "Applied macOS crash guards for Chrome for Testing");
|
|
140
62
|
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
* makes Chromium SIGTRAP during launch (issue #10). Move it aside so the next
|
|
144
|
-
* launch attempt gets a clean profile. Cheap — user just re-authenticates.
|
|
145
|
-
*/
|
|
146
|
-
async quarantineBrowserDataDir(browserDataDir) {
|
|
63
|
+
async authenticate(options = {}) {
|
|
64
|
+
const release = await acquireProcessLock(path.join(this.config.sessionDir, ".auth.lock"));
|
|
147
65
|
try {
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
66
|
+
// Even a cookie-only SAML redirect can issue an MFA push. Suppress all
|
|
67
|
+
// automatic browser attempts during cooldown; HTTP token refresh runs
|
|
68
|
+
// independently before this entrypoint and remains available.
|
|
69
|
+
if (options.automatic)
|
|
70
|
+
await this.cooldown.assertAllowed();
|
|
71
|
+
const token = await this.attemptAuthentication();
|
|
72
|
+
await options.onAuthenticated?.(token);
|
|
73
|
+
await this.cooldown.clear();
|
|
74
|
+
return token;
|
|
152
75
|
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
try {
|
|
156
|
-
await fs.rm(browserDataDir, { recursive: true, force: true });
|
|
157
|
-
log("WARN", "Deleted corrupted browser profile — starting fresh");
|
|
158
|
-
}
|
|
159
|
-
catch (rmError) {
|
|
160
|
-
log("WARN", "Failed to quarantine browser profile", rmError);
|
|
161
|
-
}
|
|
76
|
+
finally {
|
|
77
|
+
await release();
|
|
162
78
|
}
|
|
163
79
|
}
|
|
164
|
-
async
|
|
165
|
-
let
|
|
80
|
+
async attemptAuthentication() {
|
|
81
|
+
let browser;
|
|
82
|
+
let context;
|
|
83
|
+
let page;
|
|
84
|
+
let listener;
|
|
85
|
+
let interrupted = false;
|
|
86
|
+
const closeOnSignal = () => {
|
|
87
|
+
interrupted = true;
|
|
88
|
+
void browser?.close().catch(() => { });
|
|
89
|
+
};
|
|
166
90
|
try {
|
|
167
|
-
|
|
168
|
-
await
|
|
169
|
-
const
|
|
170
|
-
if (
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
await
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
viewport: { width: 1280, height: 720 },
|
|
187
|
-
args: BrowserAuth.buildChromiumArgs(),
|
|
188
|
-
timeout: 60000,
|
|
189
|
-
};
|
|
190
|
-
context = await this.launchBrowserWithRetry(browserDataDir, launchOptions);
|
|
191
|
-
log("INFO", "Browser context launched");
|
|
192
|
-
// If the user Ctrl+C's while Chrome is running, Node tears down the
|
|
193
|
-
// subprocess with SIGKILL — Chrome writes "Crashed" to exit_type, the
|
|
194
|
-
// LaunchServices crash counter ticks up, and the next launch can SIGTRAP.
|
|
195
|
-
// Hook SIGINT/SIGTERM so we close the context gracefully first. See issue #10.
|
|
196
|
-
const contextRef = context;
|
|
197
|
-
const cleanShutdown = async (signal) => {
|
|
198
|
-
log("WARN", `Received ${signal} — closing browser cleanly`);
|
|
199
|
-
try {
|
|
200
|
-
await contextRef.close();
|
|
201
|
-
}
|
|
202
|
-
catch {
|
|
203
|
-
// Already closing
|
|
204
|
-
}
|
|
205
|
-
process.exit(130);
|
|
206
|
-
};
|
|
207
|
-
process.once("SIGINT", cleanShutdown);
|
|
208
|
-
process.once("SIGTERM", cleanShutdown);
|
|
209
|
-
// Load saved storage state if it exists (cookies + localStorage)
|
|
210
|
-
// This works around Playwright bug #36139 where session cookies don't persist
|
|
211
|
-
await this.loadStorageState(context);
|
|
212
|
-
const page = context.pages()[0] || (await context.newPage());
|
|
213
|
-
// CRITICAL: Set up token interception BEFORE navigation
|
|
214
|
-
// Use longer timeout for manual login (5 min) vs automated SSO (2 min)
|
|
215
|
-
const interceptTimeout = this.ssoFlow.hasCredentials() ? 120000 : 300000;
|
|
216
|
-
const tokenPromise = this.setupTokenInterception(page, interceptTimeout);
|
|
217
|
-
// Navigate and login if needed
|
|
218
|
-
const alreadyAuthenticated = await this.navigateAndLogin(page);
|
|
219
|
-
// Run the extraction strategy chain on BOTH paths. Modern Brightspace's
|
|
220
|
-
// /d2l/home uses cookie auth and emits no Bearer header, so the passive
|
|
221
|
-
// interceptor never fires on a fresh manual login — the chain rescues
|
|
222
|
-
// us via localStorage instead. See issue #10.
|
|
223
|
-
log("INFO", alreadyAuthenticated
|
|
224
|
-
? "Session cookies active — trying to extract API token"
|
|
225
|
-
: "Login complete — extracting API token from session");
|
|
226
|
-
const extracted = await this.tryExtractToken(page, context);
|
|
227
|
-
if (extracted) {
|
|
228
|
-
const material = await this.harvestSessionMaterial(page, context);
|
|
229
|
-
await this.saveStorageState(context);
|
|
230
|
-
log("INFO", "Authentication complete");
|
|
231
|
-
return { ...extracted, ...material };
|
|
232
|
-
}
|
|
233
|
-
// Last resort for the cookie-restore path: clear cookies, force full
|
|
234
|
-
// re-login through SSO, and race the passive listener as a final fallback.
|
|
235
|
-
if (alreadyAuthenticated) {
|
|
236
|
-
log("WARN", "Could not extract valid token from existing session, forcing re-login");
|
|
237
|
-
await context.clearCookies();
|
|
238
|
-
await page.close();
|
|
239
|
-
const freshPage = await context.newPage();
|
|
240
|
-
const freshTokenPromise = this.setupTokenInterception(freshPage);
|
|
241
|
-
await this.navigateAndLogin(freshPage);
|
|
242
|
-
const freshExtracted = await this.tryExtractToken(freshPage, context);
|
|
243
|
-
if (freshExtracted) {
|
|
244
|
-
const material = await this.harvestSessionMaterial(freshPage, context);
|
|
245
|
-
await this.saveStorageState(context);
|
|
246
|
-
log("INFO", "Authentication complete");
|
|
247
|
-
return { ...freshExtracted, ...material };
|
|
248
|
-
}
|
|
249
|
-
const accessToken = await freshTokenPromise;
|
|
250
|
-
log("INFO", "Bearer token captured after forced re-login");
|
|
251
|
-
const now = Date.now();
|
|
252
|
-
const tokenData = {
|
|
253
|
-
accessToken,
|
|
254
|
-
capturedAt: now,
|
|
255
|
-
expiresAt: now + this.config.tokenTtl * 1000,
|
|
256
|
-
source: "browser",
|
|
257
|
-
...(await this.harvestSessionMaterial(freshPage, context)),
|
|
258
|
-
};
|
|
259
|
-
await this.saveStorageState(context);
|
|
260
|
-
return tokenData;
|
|
261
|
-
}
|
|
262
|
-
// Fresh-login final fallback: wait on the passive listener.
|
|
263
|
-
// Rarely reached in practice — tryExtractToken typically hits localStorage first.
|
|
264
|
-
log("INFO", "Waiting for Bearer token from network interception");
|
|
265
|
-
const accessToken = await tokenPromise;
|
|
266
|
-
log("INFO", "Bearer token captured successfully");
|
|
267
|
-
const now = Date.now();
|
|
268
|
-
const tokenData = {
|
|
269
|
-
accessToken,
|
|
270
|
-
capturedAt: now,
|
|
271
|
-
expiresAt: now + this.config.tokenTtl * 1000,
|
|
272
|
-
source: "browser",
|
|
273
|
-
...(await this.harvestSessionMaterial(page, context)),
|
|
91
|
+
const state = await this.stateStore.load();
|
|
92
|
+
const { chromium } = await import("playwright");
|
|
93
|
+
const args = ["--disable-blink-features=AutomationControlled"];
|
|
94
|
+
if (BrowserAuth.isWSLOrDocker())
|
|
95
|
+
args.push("--no-sandbox", "--disable-setuid-sandbox");
|
|
96
|
+
// Use Playwright's own timeout, which cleans up an unsuccessful launch.
|
|
97
|
+
browser = await chromium.launch({ headless: true, timeout: 60000, args });
|
|
98
|
+
process.once("SIGINT", closeOnSignal);
|
|
99
|
+
process.once("SIGTERM", closeOnSignal);
|
|
100
|
+
context = await browser.newContext({ viewport: { width: 1280, height: 720 }, storageState: state });
|
|
101
|
+
page = await context.newPage();
|
|
102
|
+
let captured;
|
|
103
|
+
listener = (request) => {
|
|
104
|
+
const url = new URL(request.url());
|
|
105
|
+
if (url.origin !== new URL(this.config.baseUrl).origin || !url.pathname.startsWith("/d2l/"))
|
|
106
|
+
return;
|
|
107
|
+
const header = request.headers().authorization;
|
|
108
|
+
if (header?.startsWith("Bearer "))
|
|
109
|
+
captured = header.slice(7);
|
|
274
110
|
};
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
if (
|
|
286
|
-
|
|
111
|
+
page.on("request", listener);
|
|
112
|
+
await this.navigateAndLogin(page);
|
|
113
|
+
// Persist the verified browser state before token acquisition. Token
|
|
114
|
+
// minting can fail independently, and a temporary outage must not throw
|
|
115
|
+
// away newly renewed Entra or Brightspace cookies.
|
|
116
|
+
await this.stateStore.save(await context.storageState());
|
|
117
|
+
const material = await this.harvestSessionMaterial(page, context);
|
|
118
|
+
let token = null;
|
|
119
|
+
if (material.cookieHeader && material.csrfToken) {
|
|
120
|
+
const minted = await mintAccessToken({ baseUrl: this.config.baseUrl, cookieHeader: material.cookieHeader, csrfToken: material.csrfToken });
|
|
121
|
+
if (minted.ok) {
|
|
122
|
+
token = { accessToken: minted.accessToken, capturedAt: Date.now(), expiresAt: Date.now() + this.config.tokenTtl * 1000, source: "browser" };
|
|
287
123
|
}
|
|
288
|
-
else if (
|
|
289
|
-
|
|
124
|
+
else if (minted.reason === "transport") {
|
|
125
|
+
throw new BrowserAuthTransportError(`Token mint temporarily failed: ${minted.detail ?? "network unavailable"}. Saved sign-in state is preserved.`);
|
|
290
126
|
}
|
|
291
|
-
else
|
|
292
|
-
|
|
127
|
+
else {
|
|
128
|
+
throw new BrowserAuthError("Brightspace session expired while acquiring an API token. Retry authentication.", "token_extraction");
|
|
293
129
|
}
|
|
294
130
|
}
|
|
295
|
-
|
|
296
|
-
|
|
131
|
+
token ??= await this.tryExtractToken(page, context);
|
|
132
|
+
if (!token && captured && await this.validateToken(captured)) {
|
|
133
|
+
token = { accessToken: captured, capturedAt: Date.now(), expiresAt: Date.now() + this.config.tokenTtl * 1000, source: "browser" };
|
|
297
134
|
}
|
|
298
|
-
|
|
135
|
+
if (!token)
|
|
136
|
+
throw new BrowserAuthError("Brightspace did not provide a usable API token. Saved SSO cookies have been preserved.", "token_extraction");
|
|
137
|
+
if (interrupted)
|
|
138
|
+
throw new BrowserAuthError("Authentication interrupted", "interrupted");
|
|
139
|
+
log("INFO", "Headless authentication complete");
|
|
140
|
+
return { ...token, ...material, tenantOrigin: new URL(this.config.baseUrl).origin };
|
|
299
141
|
}
|
|
300
142
|
finally {
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
// Context may already be closed (e.g. browser crashed or was closed externally).
|
|
308
|
-
// This is common on Windows where the browser process can terminate unexpectedly.
|
|
309
|
-
log("DEBUG", "Browser context already closed or failed to close", closeError);
|
|
310
|
-
}
|
|
311
|
-
}
|
|
143
|
+
process.removeListener("SIGINT", closeOnSignal);
|
|
144
|
+
process.removeListener("SIGTERM", closeOnSignal);
|
|
145
|
+
if (page && listener)
|
|
146
|
+
page.removeListener("request", listener);
|
|
147
|
+
await context?.close().catch(() => { });
|
|
148
|
+
await browser?.close().catch(() => { });
|
|
312
149
|
}
|
|
313
150
|
}
|
|
314
151
|
/**
|
|
@@ -326,7 +163,7 @@ export class BrowserAuth {
|
|
|
326
163
|
source: "browser",
|
|
327
164
|
};
|
|
328
165
|
};
|
|
329
|
-
// Strategy 0: localStorage (D2L.Fetch.Tokens)
|
|
166
|
+
// Strategy 0: localStorage (D2L.Fetch.Tokens) : fastest
|
|
330
167
|
const lsToken = await this.extractLocalStorageToken(page);
|
|
331
168
|
if (lsToken && (await this.validateToken(lsToken))) {
|
|
332
169
|
log("INFO", "Extracted valid Bearer token from localStorage");
|
|
@@ -344,7 +181,9 @@ export class BrowserAuth {
|
|
|
344
181
|
return build(lsToken2);
|
|
345
182
|
}
|
|
346
183
|
}
|
|
347
|
-
catch {
|
|
184
|
+
catch (error) {
|
|
185
|
+
if (error instanceof BrowserAuthTransportError)
|
|
186
|
+
throw error;
|
|
348
187
|
log("DEBUG", "Direct API navigation did not produce Bearer token");
|
|
349
188
|
}
|
|
350
189
|
// Strategy 2: XSRF / page JS context
|
|
@@ -370,108 +209,84 @@ export class BrowserAuth {
|
|
|
370
209
|
* Returns true if the token is accepted by D2L, false otherwise.
|
|
371
210
|
*/
|
|
372
211
|
async validateToken(token) {
|
|
212
|
+
let response;
|
|
373
213
|
try {
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
};
|
|
377
|
-
if (token.startsWith("cookie:")) {
|
|
378
|
-
headers["Cookie"] = token.substring(7);
|
|
379
|
-
}
|
|
380
|
-
else {
|
|
381
|
-
headers["Authorization"] = `Bearer ${token}`;
|
|
382
|
-
}
|
|
383
|
-
const response = await fetch(`${this.config.baseUrl}/d2l/api/lp/1.45/users/whoami`, {
|
|
384
|
-
method: "GET",
|
|
385
|
-
headers,
|
|
214
|
+
response = await fetch(`${this.config.baseUrl}/d2l/api/lp/1.45/users/whoami`, {
|
|
215
|
+
headers: token.startsWith("cookie:") ? { Cookie: token.slice(7) } : { Authorization: `Bearer ${token}` },
|
|
386
216
|
signal: AbortSignal.timeout(10000),
|
|
387
217
|
});
|
|
388
|
-
if (response.ok) {
|
|
389
|
-
log("DEBUG", "Token validation succeeded (whoami returned 200)");
|
|
390
|
-
return true;
|
|
391
|
-
}
|
|
392
|
-
log("DEBUG", `Token validation failed: HTTP ${response.status}`);
|
|
393
|
-
return false;
|
|
394
218
|
}
|
|
395
219
|
catch (error) {
|
|
396
|
-
|
|
397
|
-
return false;
|
|
220
|
+
throw new BrowserAuthTransportError("Token validation could not reach Brightspace.", { cause: error });
|
|
398
221
|
}
|
|
222
|
+
if (response.ok) {
|
|
223
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
224
|
+
if (!contentType.includes("json"))
|
|
225
|
+
return false;
|
|
226
|
+
let user;
|
|
227
|
+
try {
|
|
228
|
+
user = await response.json();
|
|
229
|
+
}
|
|
230
|
+
catch (error) {
|
|
231
|
+
throw new BrowserAuthTransportError("Token validation returned unreadable JSON.", { cause: error });
|
|
232
|
+
}
|
|
233
|
+
const identifier = user?.Identifier;
|
|
234
|
+
return (typeof identifier === "string" && identifier.length > 0) || (typeof identifier === "number" && Number.isFinite(identifier));
|
|
235
|
+
}
|
|
236
|
+
if (response.status === 401 || response.status === 403)
|
|
237
|
+
return false;
|
|
238
|
+
throw new BrowserAuthTransportError(`Token validation temporarily failed with HTTP ${response.status}.`);
|
|
399
239
|
}
|
|
400
|
-
/**
|
|
401
|
-
* Set up passive network request listener to capture Bearer token.
|
|
402
|
-
* MUST be called BEFORE page.goto() to avoid race condition.
|
|
403
|
-
*/
|
|
404
|
-
setupTokenInterception(page, timeoutMs = 120000) {
|
|
405
|
-
return new Promise((resolve, reject) => {
|
|
406
|
-
const timeout = setTimeout(() => {
|
|
407
|
-
reject(new BrowserAuthError(`Token interception timed out after ${timeoutMs / 1000} seconds`, "token_interception"));
|
|
408
|
-
}, timeoutMs);
|
|
409
|
-
page.on("request", (request) => {
|
|
410
|
-
const url = request.url();
|
|
411
|
-
// Look for any request with a Bearer token
|
|
412
|
-
if (url.includes("/d2l/")) {
|
|
413
|
-
const authHeader = request.headers()["authorization"];
|
|
414
|
-
if (authHeader && authHeader.startsWith("Bearer ")) {
|
|
415
|
-
const token = authHeader.substring("Bearer ".length);
|
|
416
|
-
log("DEBUG", `Token captured from request to ${url}`);
|
|
417
|
-
clearTimeout(timeout);
|
|
418
|
-
resolve(token);
|
|
419
|
-
}
|
|
420
|
-
}
|
|
421
|
-
});
|
|
422
|
-
log("DEBUG", "Token interception listener registered");
|
|
423
|
-
});
|
|
424
|
-
}
|
|
425
|
-
/**
|
|
426
|
-
* Navigate to Brightspace and login if needed.
|
|
427
|
-
* Returns true if already authenticated (cookies valid), false if SSO login was performed.
|
|
428
|
-
*/
|
|
240
|
+
/** Restore first, and only attempt credentials after a real sign-in is needed. */
|
|
429
241
|
async navigateAndLogin(page) {
|
|
242
|
+
let navigationError;
|
|
243
|
+
let response;
|
|
430
244
|
try {
|
|
431
|
-
|
|
432
|
-
await page.goto(`${this.config.baseUrl}/d2l/home`, {
|
|
245
|
+
response = await page.goto(`${this.config.baseUrl}/d2l/home`, {
|
|
433
246
|
waitUntil: "domcontentloaded",
|
|
434
|
-
timeout:
|
|
247
|
+
timeout: INITIAL_NAVIGATION_TIMEOUT_MS,
|
|
435
248
|
});
|
|
436
|
-
if (await this.awaitSilentSSO(page)) {
|
|
437
|
-
log("INFO", "Already authenticated - skipping SSO login");
|
|
438
|
-
await this.settle(page);
|
|
439
|
-
return true;
|
|
440
|
-
}
|
|
441
|
-
let loginSuccess;
|
|
442
|
-
if (this.ssoFlow.hasCredentials()) {
|
|
443
|
-
log("INFO", `Login required (stopped at ${page.url()}) - starting SSO flow`);
|
|
444
|
-
loginSuccess = await this.ssoFlow.login(page);
|
|
445
|
-
if (!loginSuccess) {
|
|
446
|
-
log("WARN", "Automated SSO flow failed or timed out. Falling back to manual login.");
|
|
447
|
-
log("INFO", "Please complete the login manually in the open browser window.");
|
|
448
|
-
loginSuccess = await this.ssoFlow.manualLogin(page);
|
|
449
|
-
}
|
|
450
|
-
}
|
|
451
|
-
else {
|
|
452
|
-
log("INFO", `Login required (stopped at ${page.url()}) - opening browser for manual login`);
|
|
453
|
-
loginSuccess = await this.ssoFlow.manualLogin(page);
|
|
454
|
-
}
|
|
455
|
-
if (!loginSuccess) {
|
|
456
|
-
throw new BrowserAuthError("Manual login flow failed", "manual_login");
|
|
457
|
-
}
|
|
458
|
-
await this.settle(page);
|
|
459
|
-
return false;
|
|
460
249
|
}
|
|
461
250
|
catch (error) {
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
251
|
+
// Brightspace Bar tolerates a navigation timeout because SAML may keep
|
|
252
|
+
// redirecting after Playwright stops waiting. Continue with the same
|
|
253
|
+
// bounded page-state poll, but never infer that credentials are needed
|
|
254
|
+
// from the navigation failure itself.
|
|
255
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
256
|
+
if (!/timeout|ERR_ABORTED|navigation.*interrupted/i.test(message)) {
|
|
257
|
+
throw new BrowserAuthTransportError("Could not reach Brightspace. Retry when the connection is available.", { cause: error });
|
|
258
|
+
}
|
|
259
|
+
navigationError = error;
|
|
260
|
+
log("WARN", "Initial Brightspace navigation did not settle; checking the current SSO page");
|
|
261
|
+
}
|
|
262
|
+
if (response && (response.status() >= 500 || response.status() === 429)) {
|
|
263
|
+
throw new BrowserAuthTransportError(`Brightspace temporarily returned HTTP ${response.status()}. Saved state is preserved.`);
|
|
264
|
+
}
|
|
265
|
+
if (await this.awaitSilentSSO(page)) {
|
|
266
|
+
log("INFO", "Saved session is active");
|
|
267
|
+
return true;
|
|
268
|
+
}
|
|
269
|
+
const pendingMfa = await this.isAnyOnScreen(page, SILENT_SSO.mfaChallenges);
|
|
270
|
+
if (!pendingMfa && !await this.hasCredentialPrompt(page)) {
|
|
271
|
+
throw new BrowserAuthTransportError("The sign-in page has not settled on a supported login challenge. Retry shortly; saved state is preserved.", navigationError === undefined ? undefined : { cause: navigationError });
|
|
272
|
+
}
|
|
273
|
+
if (!this.ssoFlow.hasCredentials() && !pendingMfa) {
|
|
274
|
+
throw new UnsupportedAuthenticationError("Headless sign-in requires saved credentials. Run brightspace-mcp-server setup.");
|
|
465
275
|
}
|
|
466
|
-
}
|
|
467
|
-
/** Let the page finish loading, without letting a slow one fail the run. */
|
|
468
|
-
async settle(page) {
|
|
469
276
|
try {
|
|
470
|
-
await
|
|
277
|
+
if (!await this.ssoFlow.login(page)) {
|
|
278
|
+
throw new UnsupportedAuthenticationError("The identity provider could not complete headless sign-in.");
|
|
279
|
+
}
|
|
471
280
|
}
|
|
472
|
-
catch {
|
|
473
|
-
|
|
281
|
+
catch (error) {
|
|
282
|
+
if (error instanceof MfaApprovalError)
|
|
283
|
+
await this.cooldown.recordMfaFailure();
|
|
284
|
+
throw error;
|
|
285
|
+
}
|
|
286
|
+
if (!await this.hasLiveSession(page)) {
|
|
287
|
+
throw new BrowserAuthError("Sign-in did not produce a verified Brightspace session.", "session_validation");
|
|
474
288
|
}
|
|
289
|
+
return false;
|
|
475
290
|
}
|
|
476
291
|
/**
|
|
477
292
|
* Give the SSO chain a bounded window to finish with no human input.
|
|
@@ -479,20 +294,57 @@ export class BrowserAuth {
|
|
|
479
294
|
*/
|
|
480
295
|
async awaitSilentSSO(page) {
|
|
481
296
|
const deadline = Date.now() + SILENT_SSO_TIMEOUT_MS;
|
|
297
|
+
let accountHintAttempted = false;
|
|
298
|
+
let passwordPromptPolls = 0;
|
|
482
299
|
do {
|
|
483
300
|
if (await this.hasLiveSession(page))
|
|
484
301
|
return true;
|
|
485
|
-
//
|
|
486
|
-
//
|
|
487
|
-
if (await this.
|
|
488
|
-
|
|
302
|
+
// Only a visible supported challenge justifies entering credentials or
|
|
303
|
+
// waiting for MFA. A stalled redirect is not proof of session expiry.
|
|
304
|
+
if (await this.isAnyOnScreen(page, SILENT_SSO.emailFields)) {
|
|
305
|
+
if (!accountHintAttempted && this.ssoFlow.identifyAccount) {
|
|
306
|
+
accountHintAttempted = true;
|
|
307
|
+
if (await this.ssoFlow.identifyAccount(page)) {
|
|
308
|
+
log("DEBUG", "Submitted the configured account name to resume saved SSO");
|
|
309
|
+
await page.waitForTimeout(SILENT_SSO_POLL_MS);
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
log("INFO", "The identity provider requires an account login");
|
|
489
314
|
return false;
|
|
490
315
|
}
|
|
491
|
-
await this.
|
|
492
|
-
|
|
316
|
+
if (await this.isAnyOnScreen(page, SILENT_SSO.mfaChallenges)) {
|
|
317
|
+
log("INFO", "The identity provider requires a login challenge");
|
|
318
|
+
return false;
|
|
319
|
+
}
|
|
320
|
+
if (await this.isAnyOnScreen(page, SILENT_SSO.credentialFields)) {
|
|
321
|
+
passwordPromptPolls += 1;
|
|
322
|
+
// Entra can briefly expose a password-shaped control while its email
|
|
323
|
+
// view initializes. Brightspace Bar polls the evolving page instead of
|
|
324
|
+
// concluding from that first transient snapshot.
|
|
325
|
+
if (passwordPromptPolls >= 2) {
|
|
326
|
+
log("INFO", "The identity provider requires a login challenge");
|
|
327
|
+
return false;
|
|
328
|
+
}
|
|
329
|
+
await page.waitForTimeout(SILENT_SSO_POLL_MS);
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
passwordPromptPolls = 0;
|
|
333
|
+
try {
|
|
334
|
+
await this.clickSilentSurfaces(page);
|
|
335
|
+
await page.waitForTimeout(SILENT_SSO_POLL_MS);
|
|
336
|
+
}
|
|
337
|
+
catch (error) {
|
|
338
|
+
if (error instanceof BrowserAuthError || error instanceof BrowserAuthTransportError)
|
|
339
|
+
throw error;
|
|
340
|
+
throw new BrowserAuthTransportError("Silent sign-in could not reach the identity provider. Saved state is preserved.", { cause: error });
|
|
341
|
+
}
|
|
493
342
|
} while (Date.now() < deadline);
|
|
494
|
-
|
|
495
|
-
|
|
343
|
+
throw new BrowserAuthTransportError("Silent sign-in did not reach Brightspace or a supported login challenge within 30 seconds. Retry shortly; saved state is preserved.");
|
|
344
|
+
}
|
|
345
|
+
async hasCredentialPrompt(page) {
|
|
346
|
+
return await this.isAnyOnScreen(page, SILENT_SSO.emailFields) ||
|
|
347
|
+
await this.isAnyOnScreen(page, SILENT_SSO.credentialFields);
|
|
496
348
|
}
|
|
497
349
|
/**
|
|
498
350
|
* A POSITIVE session check, never "the URL doesn't look like a login page".
|
|
@@ -502,6 +354,13 @@ export class BrowserAuth {
|
|
|
502
354
|
*/
|
|
503
355
|
async hasLiveSession(page) {
|
|
504
356
|
try {
|
|
357
|
+
const location = new URL(page.url());
|
|
358
|
+
if (location.origin !== new URL(this.config.baseUrl).origin)
|
|
359
|
+
return false;
|
|
360
|
+
// The /d2l/login shell also exposes D2L.LP and stale session cookies.
|
|
361
|
+
// Only the requested authenticated home page is evidence of success.
|
|
362
|
+
if (!/^\/d2l\/home(?:\/|$)/.test(location.pathname))
|
|
363
|
+
return false;
|
|
505
364
|
const cookies = await page.context().cookies(this.config.baseUrl);
|
|
506
365
|
if (!cookies.some((c) => c.name === "d2lSessionVal" && Boolean(c.value))) {
|
|
507
366
|
return false;
|
|
@@ -511,8 +370,8 @@ export class BrowserAuth {
|
|
|
511
370
|
return d2l !== undefined && Boolean(d2l.LP);
|
|
512
371
|
});
|
|
513
372
|
}
|
|
514
|
-
catch {
|
|
515
|
-
|
|
373
|
+
catch (error) {
|
|
374
|
+
throw new BrowserAuthTransportError("The browser could not verify the saved Brightspace session. Saved state is preserved.", { cause: error });
|
|
516
375
|
}
|
|
517
376
|
}
|
|
518
377
|
/**
|
|
@@ -522,6 +381,11 @@ export class BrowserAuth {
|
|
|
522
381
|
async clickSilentSurfaces(page) {
|
|
523
382
|
const url = page.url();
|
|
524
383
|
if (url.includes("/d2l/login")) {
|
|
384
|
+
// The Purdue buttons are not ordinary links. Follow the school's known
|
|
385
|
+
// SAML entry before deciding whether saved Microsoft state has expired.
|
|
386
|
+
await this.ssoFlow.prepareLogin?.(page);
|
|
387
|
+
if (!page.url().includes("/d2l/login"))
|
|
388
|
+
return;
|
|
525
389
|
// Brightspace renders the campus buttons in a shadow DOM, which
|
|
526
390
|
// Playwright's selectors see through. A configured campus is matched by
|
|
527
391
|
// name; otherwise the selector's own SAML link is the only affordance
|
|
@@ -555,7 +419,20 @@ export class BrowserAuth {
|
|
|
555
419
|
}
|
|
556
420
|
/** Visibility without the actionability wait, so a poll keeps its rhythm. */
|
|
557
421
|
async isOnScreen(page, selector) {
|
|
558
|
-
|
|
422
|
+
try {
|
|
423
|
+
return await page.locator(selector).first().isVisible();
|
|
424
|
+
}
|
|
425
|
+
catch (error) {
|
|
426
|
+
throw new BrowserAuthTransportError("The browser could not inspect the sign-in page. Saved state is preserved.", { cause: error });
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
/** Check each selector independently so a hidden earlier match cannot mask a visible one. */
|
|
430
|
+
async isAnyOnScreen(page, selectors) {
|
|
431
|
+
for (const selector of selectors) {
|
|
432
|
+
if (await this.isOnScreen(page, selector))
|
|
433
|
+
return true;
|
|
434
|
+
}
|
|
435
|
+
return false;
|
|
559
436
|
}
|
|
560
437
|
/**
|
|
561
438
|
* Try to extract Bearer token from D2L's localStorage.
|
|
@@ -650,7 +527,15 @@ export class BrowserAuth {
|
|
|
650
527
|
timeout: 15000,
|
|
651
528
|
});
|
|
652
529
|
}
|
|
653
|
-
|
|
530
|
+
}
|
|
531
|
+
catch (error) {
|
|
532
|
+
log("DEBUG", "Could not navigate to Brightspace before XSRF extraction", error);
|
|
533
|
+
return null;
|
|
534
|
+
}
|
|
535
|
+
// Brightspace Bar retries each evaluation independently because a redirect
|
|
536
|
+
// can destroy one JavaScript context while the next one is already valid.
|
|
537
|
+
for (let attempt = 0; attempt < 10; attempt += 1) {
|
|
538
|
+
const token = await page.evaluate(() => {
|
|
654
539
|
const d2l = window.D2L;
|
|
655
540
|
try {
|
|
656
541
|
const lp = d2l?.LP;
|
|
@@ -659,7 +544,7 @@ export class BrowserAuth {
|
|
|
659
544
|
const xsrf = auth?.Xsrf;
|
|
660
545
|
const getToken = xsrf?.GetXsrfToken;
|
|
661
546
|
if (getToken) {
|
|
662
|
-
const value = getToken();
|
|
547
|
+
const value = getToken.call(xsrf);
|
|
663
548
|
if (value)
|
|
664
549
|
return value;
|
|
665
550
|
}
|
|
@@ -669,12 +554,15 @@ export class BrowserAuth {
|
|
|
669
554
|
}
|
|
670
555
|
const metaToken = document.querySelector('meta[name="d2l-xsrf-token"]');
|
|
671
556
|
return metaToken ? metaToken.getAttribute("content") : null;
|
|
557
|
+
}).catch((error) => {
|
|
558
|
+
log("DEBUG", "XSRF evaluation raced a page transition, retrying", error);
|
|
559
|
+
return null;
|
|
672
560
|
});
|
|
561
|
+
if (token)
|
|
562
|
+
return token;
|
|
563
|
+
await page.waitForTimeout(1000);
|
|
673
564
|
}
|
|
674
|
-
|
|
675
|
-
log("DEBUG", "XSRF-only extraction failed", error);
|
|
676
|
-
return null;
|
|
677
|
-
}
|
|
565
|
+
return null;
|
|
678
566
|
}
|
|
679
567
|
/**
|
|
680
568
|
* Try to extract XSRF/API token from D2L's JavaScript context.
|
|
@@ -703,7 +591,7 @@ export class BrowserAuth {
|
|
|
703
591
|
const xsrf = auth?.Xsrf;
|
|
704
592
|
const getToken = xsrf?.GetXsrfToken;
|
|
705
593
|
if (getToken)
|
|
706
|
-
return getToken();
|
|
594
|
+
return getToken.call(xsrf);
|
|
707
595
|
}
|
|
708
596
|
catch {
|
|
709
597
|
// Not available
|
|
@@ -761,209 +649,5 @@ export class BrowserAuth {
|
|
|
761
649
|
return null;
|
|
762
650
|
}
|
|
763
651
|
}
|
|
764
|
-
/**
|
|
765
|
-
* Load previously saved storage state (cookies + localStorage).
|
|
766
|
-
* Workaround for Playwright bug #36139: session cookies don't persist in persistent context.
|
|
767
|
-
*/
|
|
768
|
-
async loadStorageState(context) {
|
|
769
|
-
try {
|
|
770
|
-
const storageStatePath = path.join(this.config.sessionDir, "storage-state.json");
|
|
771
|
-
// Check if storage state file exists
|
|
772
|
-
let stats;
|
|
773
|
-
try {
|
|
774
|
-
stats = await fs.stat(storageStatePath);
|
|
775
|
-
}
|
|
776
|
-
catch {
|
|
777
|
-
log("DEBUG", "No existing storage state to load");
|
|
778
|
-
return;
|
|
779
|
-
}
|
|
780
|
-
// Skip loading stale cookies that would fake "already authenticated"
|
|
781
|
-
const ageMs = Date.now() - stats.mtimeMs;
|
|
782
|
-
const maxAgeMs = this.config.tokenTtl * 1000;
|
|
783
|
-
if (ageMs > maxAgeMs) {
|
|
784
|
-
log("INFO", `Storage state is ${Math.round(ageMs / 60000)}min old (TTL ${this.config.tokenTtl}s) — skipping stale cookies`);
|
|
785
|
-
return;
|
|
786
|
-
}
|
|
787
|
-
// Read storage state
|
|
788
|
-
const stateJson = await fs.readFile(storageStatePath, "utf-8");
|
|
789
|
-
const state = JSON.parse(stateJson);
|
|
790
|
-
// Restore cookies
|
|
791
|
-
if (state.cookies && state.cookies.length > 0) {
|
|
792
|
-
await context.addCookies(state.cookies);
|
|
793
|
-
log("INFO", `Restored ${state.cookies.length} cookies from storage state`);
|
|
794
|
-
}
|
|
795
|
-
// Restore localStorage for each origin
|
|
796
|
-
if (state.origins && state.origins.length > 0) {
|
|
797
|
-
for (const origin of state.origins) {
|
|
798
|
-
if (origin.localStorage && origin.localStorage.length > 0) {
|
|
799
|
-
let tempPage = null;
|
|
800
|
-
try {
|
|
801
|
-
// Create a temporary page to set localStorage
|
|
802
|
-
tempPage = await context.newPage();
|
|
803
|
-
await tempPage.goto(origin.origin, { timeout: 10000 });
|
|
804
|
-
// Set each localStorage item
|
|
805
|
-
await tempPage.evaluate((items) => {
|
|
806
|
-
for (const item of items) {
|
|
807
|
-
localStorage.setItem(item.name, item.value);
|
|
808
|
-
}
|
|
809
|
-
}, origin.localStorage);
|
|
810
|
-
log("INFO", `Restored ${origin.localStorage.length} localStorage items for ${origin.origin}`);
|
|
811
|
-
}
|
|
812
|
-
catch (originError) {
|
|
813
|
-
log("WARN", `Failed to restore localStorage for ${origin.origin}`, originError);
|
|
814
|
-
}
|
|
815
|
-
finally {
|
|
816
|
-
if (tempPage) {
|
|
817
|
-
try {
|
|
818
|
-
await tempPage.close();
|
|
819
|
-
}
|
|
820
|
-
catch {
|
|
821
|
-
// Page may already be closed
|
|
822
|
-
}
|
|
823
|
-
}
|
|
824
|
-
}
|
|
825
|
-
}
|
|
826
|
-
}
|
|
827
|
-
}
|
|
828
|
-
log("INFO", "Storage state restored successfully");
|
|
829
|
-
}
|
|
830
|
-
catch (error) {
|
|
831
|
-
log("WARN", "Failed to load storage state", error);
|
|
832
|
-
}
|
|
833
|
-
}
|
|
834
|
-
async saveStorageState(context) {
|
|
835
|
-
try {
|
|
836
|
-
const storageStatePath = path.join(this.config.sessionDir, "storage-state.json");
|
|
837
|
-
await context.storageState({ path: storageStatePath });
|
|
838
|
-
await fs.chmod(storageStatePath, 0o600);
|
|
839
|
-
log("DEBUG", `Storage state saved to ${storageStatePath}`);
|
|
840
|
-
}
|
|
841
|
-
catch (error) {
|
|
842
|
-
log("WARN", "Failed to save storage state", error);
|
|
843
|
-
}
|
|
844
|
-
}
|
|
845
|
-
/**
|
|
846
|
-
* Launch browser with retry logic.
|
|
847
|
-
* Windows is prone to 180s launch timeouts (Playwright issue #22117) caused by
|
|
848
|
-
* lingering Chromium processes, antivirus interference, or resource contention.
|
|
849
|
-
* On timeout, we clear lock files and retry once.
|
|
850
|
-
*/
|
|
851
|
-
async launchBrowserWithRetry(browserDataDir, options) {
|
|
852
|
-
// The only place the browser is needed, so the only place it is loaded.
|
|
853
|
-
const { chromium } = await import("playwright");
|
|
854
|
-
// Validate lock files before every launch attempt
|
|
855
|
-
await this.validateAndClearLockFiles(browserDataDir);
|
|
856
|
-
try {
|
|
857
|
-
// Wrap in Promise.race so a hung launch (e.g. stale SingletonLock
|
|
858
|
-
// that wasn't caught) still falls into the retry path
|
|
859
|
-
const launchPromise = chromium.launchPersistentContext(browserDataDir, options);
|
|
860
|
-
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error("Timeout: browser launch hung")), options.timeout));
|
|
861
|
-
return await Promise.race([launchPromise, timeoutPromise]);
|
|
862
|
-
}
|
|
863
|
-
catch (error) {
|
|
864
|
-
const errMsg = error instanceof Error ? error.message : String(error);
|
|
865
|
-
const isTimeout = errMsg.includes("Timeout") || errMsg.includes("timeout") || errMsg.includes("hung");
|
|
866
|
-
// macOS: Chrome for Testing died during launch with SIGTRAP / browser
|
|
867
|
-
// closed. The profile is likely corrupted from prior crashes. Quarantine
|
|
868
|
-
// it, reapply the crash guards, and retry with a fresh profile.
|
|
869
|
-
// See issue #10.
|
|
870
|
-
const isMacBrowserCrash = process.platform === "darwin" &&
|
|
871
|
-
(errMsg.includes("Target page, context or browser has been closed") ||
|
|
872
|
-
errMsg.includes("SIGTRAP") ||
|
|
873
|
-
errMsg.includes("did exit"));
|
|
874
|
-
if (isMacBrowserCrash) {
|
|
875
|
-
log("WARN", "Chrome for Testing crashed during launch — quarantining profile and retrying");
|
|
876
|
-
await this.quarantineBrowserDataDir(browserDataDir);
|
|
877
|
-
await BrowserAuth.applyMacOSCrashGuard();
|
|
878
|
-
return await chromium.launchPersistentContext(browserDataDir, {
|
|
879
|
-
...options,
|
|
880
|
-
timeout: 90000,
|
|
881
|
-
});
|
|
882
|
-
}
|
|
883
|
-
if (isTimeout) {
|
|
884
|
-
log("WARN", "Browser launch timed out — clearing lock files and retrying");
|
|
885
|
-
await this.validateAndClearLockFiles(browserDataDir);
|
|
886
|
-
return await chromium.launchPersistentContext(browserDataDir, {
|
|
887
|
-
...options,
|
|
888
|
-
timeout: 90000,
|
|
889
|
-
});
|
|
890
|
-
}
|
|
891
|
-
throw error;
|
|
892
|
-
}
|
|
893
|
-
}
|
|
894
|
-
/**
|
|
895
|
-
* Validate and remove stale Chromium lock files from the browser data directory.
|
|
896
|
-
* Playwright's persistent context uses Chromium's SingletonLock mechanism.
|
|
897
|
-
* If the browser is killed unexpectedly (antivirus, force close, crash),
|
|
898
|
-
* these lock files persist and block all future launch attempts.
|
|
899
|
-
*
|
|
900
|
-
* For SingletonLock (a symlink whose target is "hostname-pid"), we check
|
|
901
|
-
* whether the owning process is still alive before removing it. This avoids
|
|
902
|
-
* deleting a lock held by a legitimate running instance.
|
|
903
|
-
*/
|
|
904
|
-
async validateAndClearLockFiles(browserDataDir) {
|
|
905
|
-
// Ensure the directory exists before scanning
|
|
906
|
-
try {
|
|
907
|
-
await fs.access(browserDataDir);
|
|
908
|
-
}
|
|
909
|
-
catch {
|
|
910
|
-
return; // Directory doesn't exist yet, nothing to clean
|
|
911
|
-
}
|
|
912
|
-
const lockFiles = ["SingletonLock", "SingletonCookie", "SingletonSocket"];
|
|
913
|
-
for (const lockFile of lockFiles) {
|
|
914
|
-
const lockPath = path.join(browserDataDir, lockFile);
|
|
915
|
-
try {
|
|
916
|
-
const stat = await fs.lstat(lockPath);
|
|
917
|
-
if (stat.isSymbolicLink() && lockFile === "SingletonLock") {
|
|
918
|
-
// SingletonLock is a symlink with target "hostname-pid"
|
|
919
|
-
const target = await fs.readlink(lockPath);
|
|
920
|
-
const dashIdx = target.lastIndexOf("-");
|
|
921
|
-
if (dashIdx > 0) {
|
|
922
|
-
const lockHostname = target.substring(0, dashIdx);
|
|
923
|
-
const lockPid = parseInt(target.substring(dashIdx + 1), 10);
|
|
924
|
-
if (lockHostname !== os.hostname()) {
|
|
925
|
-
// Lock from a different machine (e.g. NFS home dir, or stale after network change)
|
|
926
|
-
log("WARN", `Removing SingletonLock from different host (${lockHostname} vs ${os.hostname()})`);
|
|
927
|
-
await fs.unlink(lockPath);
|
|
928
|
-
continue;
|
|
929
|
-
}
|
|
930
|
-
if (!isNaN(lockPid)) {
|
|
931
|
-
try {
|
|
932
|
-
process.kill(lockPid, 0); // Signal 0 checks if process exists
|
|
933
|
-
// Process is alive, leave the lock alone
|
|
934
|
-
log("DEBUG", `SingletonLock held by live process ${lockPid}, skipping`);
|
|
935
|
-
continue;
|
|
936
|
-
}
|
|
937
|
-
catch (killErr) {
|
|
938
|
-
const code = killErr.code;
|
|
939
|
-
if (code === "ESRCH") {
|
|
940
|
-
// Process is dead, safe to remove
|
|
941
|
-
log("WARN", `Removing SingletonLock from dead process ${lockPid}`);
|
|
942
|
-
await fs.unlink(lockPath);
|
|
943
|
-
continue;
|
|
944
|
-
}
|
|
945
|
-
if (code === "EPERM") {
|
|
946
|
-
// Process exists but we lack permissions, leave it alone
|
|
947
|
-
log("DEBUG", `SingletonLock held by process ${lockPid} (EPERM), skipping`);
|
|
948
|
-
continue;
|
|
949
|
-
}
|
|
950
|
-
}
|
|
951
|
-
}
|
|
952
|
-
}
|
|
953
|
-
// Could not parse target, remove as a safety measure
|
|
954
|
-
log("WARN", `Removing unparseable SingletonLock: ${target}`);
|
|
955
|
-
await fs.unlink(lockPath);
|
|
956
|
-
}
|
|
957
|
-
else {
|
|
958
|
-
// Regular file (SingletonCookie, SingletonSocket) or unknown symlink
|
|
959
|
-
await fs.unlink(lockPath);
|
|
960
|
-
log("WARN", `Removed stale lock file: ${lockFile}`);
|
|
961
|
-
}
|
|
962
|
-
}
|
|
963
|
-
catch {
|
|
964
|
-
// File doesn't exist, expected in normal operation
|
|
965
|
-
}
|
|
966
|
-
}
|
|
967
|
-
}
|
|
968
652
|
}
|
|
969
653
|
//# sourceMappingURL=browser-auth.js.map
|