brightspace-mcp-server 1.6.3 → 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 +17 -86
- package/build/auth/browser-auth.d.ts.map +1 -1
- package/build/auth/browser-auth.js +256 -615
- 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,350 +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
|
};
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
constructor() {
|
|
37
|
-
super("A sign-in is needed and the browser must be visible for it");
|
|
38
|
-
this.name = "HeadlessLoginRequiredError";
|
|
27
|
+
export class BrowserAuthTransportError extends Error {
|
|
28
|
+
code = "AUTH_TRANSPORT";
|
|
29
|
+
constructor(message, options) {
|
|
30
|
+
super(message, options);
|
|
31
|
+
this.name = "BrowserAuthTransportError";
|
|
39
32
|
}
|
|
40
33
|
}
|
|
41
|
-
/** Whether the current attempt has to start over with a window on screen. */
|
|
42
|
-
export function needsVisibleBrowser({ launchedHeadless }) {
|
|
43
|
-
return launchedHeadless;
|
|
44
|
-
}
|
|
45
34
|
export class BrowserAuth {
|
|
46
35
|
config;
|
|
47
36
|
ssoFlow;
|
|
48
|
-
|
|
49
|
-
|
|
37
|
+
stateStore;
|
|
38
|
+
cooldown;
|
|
50
39
|
constructor(config) {
|
|
51
40
|
this.config = config;
|
|
52
41
|
this.ssoFlow = createSSOFlow(config);
|
|
42
|
+
this.stateStore = new BrowserStateStore(config.sessionDir);
|
|
43
|
+
this.cooldown = new AuthCooldown(config.sessionDir);
|
|
53
44
|
}
|
|
54
|
-
/**
|
|
55
|
-
* Detect if running inside WSL (Windows Subsystem for Linux) or Docker.
|
|
56
|
-
* These environments require --no-sandbox for Chromium to launch.
|
|
57
|
-
*/
|
|
58
45
|
static isWSLOrDocker() {
|
|
59
46
|
try {
|
|
60
|
-
|
|
61
|
-
const procVersion = require("node:fs").readFileSync("/proc/version", "utf-8");
|
|
62
|
-
if (/microsoft|wsl/i.test(procVersion))
|
|
47
|
+
if (/microsoft|wsl/i.test(readFileSync("/proc/version", "utf8")))
|
|
63
48
|
return true;
|
|
64
49
|
}
|
|
65
|
-
catch {
|
|
66
|
-
// Not Linux or /proc not available
|
|
67
|
-
}
|
|
50
|
+
catch { /* Not WSL. */ }
|
|
68
51
|
try {
|
|
69
|
-
|
|
70
|
-
require("node:fs").accessSync("/.dockerenv");
|
|
52
|
+
accessSync("/.dockerenv");
|
|
71
53
|
return true;
|
|
72
54
|
}
|
|
73
|
-
catch {
|
|
74
|
-
// Not Docker
|
|
75
|
-
}
|
|
76
|
-
try {
|
|
77
|
-
const cgroup = require("node:fs").readFileSync("/proc/1/cgroup", "utf-8");
|
|
78
|
-
if (cgroup.includes("docker") || cgroup.includes("containerd"))
|
|
79
|
-
return true;
|
|
80
|
-
}
|
|
81
|
-
catch {
|
|
82
|
-
// Not in a container
|
|
83
|
-
}
|
|
84
|
-
return false;
|
|
85
|
-
}
|
|
86
|
-
/**
|
|
87
|
-
* Build Chromium launch args based on the current platform and environment.
|
|
88
|
-
*/
|
|
89
|
-
static buildChromiumArgs() {
|
|
90
|
-
const args = ["--disable-blink-features=AutomationControlled"];
|
|
91
|
-
if (process.platform === "win32") {
|
|
92
|
-
args.push("--disable-gpu");
|
|
93
|
-
}
|
|
94
|
-
// On macOS, NSPersistentUIRestorer is disabled via `defaults write` in
|
|
95
|
-
// applyMacOSCrashGuard() — see issue #10. Passing "-ApplePersistenceIgnoreState YES"
|
|
96
|
-
// as argv doesn't work here because Playwright's launchPersistentContext rejects
|
|
97
|
-
// non-flag positional arguments.
|
|
98
|
-
if (BrowserAuth.isWSLOrDocker()) {
|
|
99
|
-
args.push("--no-sandbox", "--disable-setuid-sandbox");
|
|
100
|
-
log("INFO", "Detected WSL/Docker environment — launching Chromium with --no-sandbox");
|
|
101
|
-
}
|
|
102
|
-
return args;
|
|
103
|
-
}
|
|
104
|
-
/**
|
|
105
|
-
* Prevent Chrome for Testing from SIGTRAP'ing on launch on macOS (issue #10).
|
|
106
|
-
*
|
|
107
|
-
* Three layers, all idempotent and cheap:
|
|
108
|
-
* 1. ApplePersistenceIgnoreState — disables NSPersistentUIRestorer's crash-prompt
|
|
109
|
-
* modal, which Chrome for Testing's AppKit bridge cannot handle.
|
|
110
|
-
* 2. IIO_LaunchInfo=0 — resets LaunchServices' per-app crash counter so macOS
|
|
111
|
-
* stops triggering the recovery pathway in the first place.
|
|
112
|
-
* 3. Nuke the Cocoa saved-application-state bundle — if it exists, AppKit tries
|
|
113
|
-
* to replay window state during launch and can crash the browser process.
|
|
114
|
-
*
|
|
115
|
-
* Runs before every launch. None of these are destructive to user data; Chrome
|
|
116
|
-
* for Testing is a disposable test profile.
|
|
117
|
-
*/
|
|
118
|
-
static async applyMacOSCrashGuard() {
|
|
119
|
-
if (process.platform !== "darwin")
|
|
120
|
-
return;
|
|
121
|
-
const { execFile } = await import("node:child_process");
|
|
122
|
-
const { promisify } = await import("node:util");
|
|
123
|
-
const execFileAsync = promisify(execFile);
|
|
55
|
+
catch { /* Not Docker. */ }
|
|
124
56
|
try {
|
|
125
|
-
|
|
126
|
-
"write",
|
|
127
|
-
"com.google.chrome.for.testing",
|
|
128
|
-
"ApplePersistenceIgnoreState",
|
|
129
|
-
"-bool",
|
|
130
|
-
"yes",
|
|
131
|
-
]);
|
|
57
|
+
return /docker|containerd/.test(readFileSync("/proc/1/cgroup", "utf8"));
|
|
132
58
|
}
|
|
133
59
|
catch {
|
|
134
|
-
|
|
135
|
-
}
|
|
136
|
-
try {
|
|
137
|
-
await execFileAsync("defaults", [
|
|
138
|
-
"write",
|
|
139
|
-
"com.google.chrome.for.testing",
|
|
140
|
-
"IIO_LaunchInfo",
|
|
141
|
-
"-int",
|
|
142
|
-
"0",
|
|
143
|
-
]);
|
|
144
|
-
}
|
|
145
|
-
catch {
|
|
146
|
-
// Non-fatal
|
|
147
|
-
}
|
|
148
|
-
try {
|
|
149
|
-
const savedState = path.join(os.homedir(), "Library", "Saved Application State", "com.google.chrome.for.testing.savedState");
|
|
150
|
-
await fs.rm(savedState, { recursive: true, force: true });
|
|
151
|
-
}
|
|
152
|
-
catch {
|
|
153
|
-
// Non-fatal
|
|
154
|
-
}
|
|
155
|
-
log("DEBUG", "Applied macOS crash guards for Chrome for Testing");
|
|
156
|
-
}
|
|
157
|
-
/**
|
|
158
|
-
* Recovery step: the current browser-data profile is corrupted in a way that
|
|
159
|
-
* makes Chromium SIGTRAP during launch (issue #10). Move it aside so the next
|
|
160
|
-
* launch attempt gets a clean profile. Cheap — user just re-authenticates.
|
|
161
|
-
*/
|
|
162
|
-
async quarantineBrowserDataDir(browserDataDir) {
|
|
163
|
-
try {
|
|
164
|
-
const stamp = Date.now();
|
|
165
|
-
const quarantined = `${browserDataDir}.corrupted.${stamp}`;
|
|
166
|
-
await fs.rename(browserDataDir, quarantined);
|
|
167
|
-
log("WARN", `Quarantined corrupted browser profile to ${quarantined} — starting fresh`);
|
|
168
|
-
}
|
|
169
|
-
catch (error) {
|
|
170
|
-
// If rename fails (permissions, missing dir), try a recursive delete instead.
|
|
171
|
-
try {
|
|
172
|
-
await fs.rm(browserDataDir, { recursive: true, force: true });
|
|
173
|
-
log("WARN", "Deleted corrupted browser profile — starting fresh");
|
|
174
|
-
}
|
|
175
|
-
catch (rmError) {
|
|
176
|
-
log("WARN", "Failed to quarantine browser profile", rmError);
|
|
177
|
-
}
|
|
60
|
+
return false;
|
|
178
61
|
}
|
|
179
62
|
}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
*
|
|
183
|
-
* Headless is only ever safe for the silent path. The moment a real sign-in
|
|
184
|
-
* is required the run starts over with a visible browser, because the MFA
|
|
185
|
-
* number match has to be readable by a human.
|
|
186
|
-
*/
|
|
187
|
-
async authenticate() {
|
|
63
|
+
async authenticate(options = {}) {
|
|
64
|
+
const release = await acquireProcessLock(path.join(this.config.sessionDir, ".auth.lock"));
|
|
188
65
|
try {
|
|
189
|
-
|
|
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;
|
|
190
75
|
}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
log("INFO", "A sign-in is needed, so the browser is reopening with a window");
|
|
194
|
-
return await this.attemptAuthentication(false);
|
|
195
|
-
}
|
|
196
|
-
throw error;
|
|
76
|
+
finally {
|
|
77
|
+
await release();
|
|
197
78
|
}
|
|
198
79
|
}
|
|
199
|
-
async attemptAuthentication(
|
|
200
|
-
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
|
+
};
|
|
201
90
|
try {
|
|
202
|
-
|
|
203
|
-
await
|
|
204
|
-
const
|
|
205
|
-
if (
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
await
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
headless,
|
|
222
|
-
viewport: { width: 1280, height: 720 },
|
|
223
|
-
args: BrowserAuth.buildChromiumArgs(),
|
|
224
|
-
timeout: 60000,
|
|
225
|
-
};
|
|
226
|
-
context = await this.launchBrowserWithRetry(browserDataDir, launchOptions);
|
|
227
|
-
log("INFO", "Browser context launched");
|
|
228
|
-
// If the user Ctrl+C's while Chrome is running, Node tears down the
|
|
229
|
-
// subprocess with SIGKILL — Chrome writes "Crashed" to exit_type, the
|
|
230
|
-
// LaunchServices crash counter ticks up, and the next launch can SIGTRAP.
|
|
231
|
-
// Hook SIGINT/SIGTERM so we close the context gracefully first. See issue #10.
|
|
232
|
-
const contextRef = context;
|
|
233
|
-
const cleanShutdown = async (signal) => {
|
|
234
|
-
log("WARN", `Received ${signal} — closing browser cleanly`);
|
|
235
|
-
try {
|
|
236
|
-
await contextRef.close();
|
|
237
|
-
}
|
|
238
|
-
catch {
|
|
239
|
-
// Already closing
|
|
240
|
-
}
|
|
241
|
-
process.exit(130);
|
|
242
|
-
};
|
|
243
|
-
process.once("SIGINT", cleanShutdown);
|
|
244
|
-
process.once("SIGTERM", cleanShutdown);
|
|
245
|
-
// Load saved storage state if it exists (cookies + localStorage)
|
|
246
|
-
// This works around Playwright bug #36139 where session cookies don't persist
|
|
247
|
-
await this.loadStorageState(context);
|
|
248
|
-
const page = context.pages()[0] || (await context.newPage());
|
|
249
|
-
// CRITICAL: Set up token interception BEFORE navigation
|
|
250
|
-
// Use longer timeout for manual login (5 min) vs automated SSO (2 min)
|
|
251
|
-
const interceptTimeout = this.ssoFlow.hasCredentials() ? 120000 : 300000;
|
|
252
|
-
const tokenPromise = this.setupTokenInterception(page, interceptTimeout);
|
|
253
|
-
// Navigate and login if needed
|
|
254
|
-
const alreadyAuthenticated = await this.navigateAndLogin(page);
|
|
255
|
-
// Run the extraction strategy chain on BOTH paths. Modern Brightspace's
|
|
256
|
-
// /d2l/home uses cookie auth and emits no Bearer header, so the passive
|
|
257
|
-
// interceptor never fires on a fresh manual login — the chain rescues
|
|
258
|
-
// us via localStorage instead. See issue #10.
|
|
259
|
-
log("INFO", alreadyAuthenticated
|
|
260
|
-
? "Session cookies active — trying to extract API token"
|
|
261
|
-
: "Login complete — extracting API token from session");
|
|
262
|
-
const extracted = await this.tryExtractToken(page, context);
|
|
263
|
-
if (extracted) {
|
|
264
|
-
const material = await this.harvestSessionMaterial(page, context);
|
|
265
|
-
await this.saveStorageState(context);
|
|
266
|
-
log("INFO", "Authentication complete");
|
|
267
|
-
return { ...extracted, ...material };
|
|
268
|
-
}
|
|
269
|
-
// Last resort for the cookie-restore path: clear cookies, force full
|
|
270
|
-
// re-login through SSO, and race the passive listener as a final fallback.
|
|
271
|
-
if (alreadyAuthenticated) {
|
|
272
|
-
log("WARN", "Could not extract valid token from existing session, forcing re-login");
|
|
273
|
-
await context.clearCookies();
|
|
274
|
-
await page.close();
|
|
275
|
-
const freshPage = await context.newPage();
|
|
276
|
-
const freshTokenPromise = this.setupTokenInterception(freshPage);
|
|
277
|
-
await this.navigateAndLogin(freshPage);
|
|
278
|
-
const freshExtracted = await this.tryExtractToken(freshPage, context);
|
|
279
|
-
if (freshExtracted) {
|
|
280
|
-
const material = await this.harvestSessionMaterial(freshPage, context);
|
|
281
|
-
await this.saveStorageState(context);
|
|
282
|
-
log("INFO", "Authentication complete");
|
|
283
|
-
return { ...freshExtracted, ...material };
|
|
284
|
-
}
|
|
285
|
-
const accessToken = await freshTokenPromise;
|
|
286
|
-
log("INFO", "Bearer token captured after forced re-login");
|
|
287
|
-
const now = Date.now();
|
|
288
|
-
const tokenData = {
|
|
289
|
-
accessToken,
|
|
290
|
-
capturedAt: now,
|
|
291
|
-
expiresAt: now + this.config.tokenTtl * 1000,
|
|
292
|
-
source: "browser",
|
|
293
|
-
...(await this.harvestSessionMaterial(freshPage, context)),
|
|
294
|
-
};
|
|
295
|
-
await this.saveStorageState(context);
|
|
296
|
-
return tokenData;
|
|
297
|
-
}
|
|
298
|
-
// Fresh-login final fallback: wait on the passive listener.
|
|
299
|
-
// Rarely reached in practice — tryExtractToken typically hits localStorage first.
|
|
300
|
-
log("INFO", "Waiting for Bearer token from network interception");
|
|
301
|
-
const accessToken = await tokenPromise;
|
|
302
|
-
log("INFO", "Bearer token captured successfully");
|
|
303
|
-
const now = Date.now();
|
|
304
|
-
const tokenData = {
|
|
305
|
-
accessToken,
|
|
306
|
-
capturedAt: now,
|
|
307
|
-
expiresAt: now + this.config.tokenTtl * 1000,
|
|
308
|
-
source: "browser",
|
|
309
|
-
...(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);
|
|
310
110
|
};
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
if (
|
|
322
|
-
|
|
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" };
|
|
323
123
|
}
|
|
324
|
-
else if (
|
|
325
|
-
|
|
124
|
+
else if (minted.reason === "transport") {
|
|
125
|
+
throw new BrowserAuthTransportError(`Token mint temporarily failed: ${minted.detail ?? "network unavailable"}. Saved sign-in state is preserved.`);
|
|
326
126
|
}
|
|
327
|
-
else
|
|
328
|
-
|
|
127
|
+
else {
|
|
128
|
+
throw new BrowserAuthError("Brightspace session expired while acquiring an API token. Retry authentication.", "token_extraction");
|
|
329
129
|
}
|
|
330
130
|
}
|
|
331
|
-
|
|
332
|
-
|
|
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" };
|
|
333
134
|
}
|
|
334
|
-
|
|
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 };
|
|
335
141
|
}
|
|
336
142
|
finally {
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
// Context may already be closed (e.g. browser crashed or was closed externally).
|
|
344
|
-
// This is common on Windows where the browser process can terminate unexpectedly.
|
|
345
|
-
log("DEBUG", "Browser context already closed or failed to close", closeError);
|
|
346
|
-
}
|
|
347
|
-
}
|
|
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(() => { });
|
|
348
149
|
}
|
|
349
150
|
}
|
|
350
151
|
/**
|
|
@@ -362,7 +163,7 @@ export class BrowserAuth {
|
|
|
362
163
|
source: "browser",
|
|
363
164
|
};
|
|
364
165
|
};
|
|
365
|
-
// Strategy 0: localStorage (D2L.Fetch.Tokens)
|
|
166
|
+
// Strategy 0: localStorage (D2L.Fetch.Tokens) : fastest
|
|
366
167
|
const lsToken = await this.extractLocalStorageToken(page);
|
|
367
168
|
if (lsToken && (await this.validateToken(lsToken))) {
|
|
368
169
|
log("INFO", "Extracted valid Bearer token from localStorage");
|
|
@@ -380,7 +181,9 @@ export class BrowserAuth {
|
|
|
380
181
|
return build(lsToken2);
|
|
381
182
|
}
|
|
382
183
|
}
|
|
383
|
-
catch {
|
|
184
|
+
catch (error) {
|
|
185
|
+
if (error instanceof BrowserAuthTransportError)
|
|
186
|
+
throw error;
|
|
384
187
|
log("DEBUG", "Direct API navigation did not produce Bearer token");
|
|
385
188
|
}
|
|
386
189
|
// Strategy 2: XSRF / page JS context
|
|
@@ -406,115 +209,84 @@ export class BrowserAuth {
|
|
|
406
209
|
* Returns true if the token is accepted by D2L, false otherwise.
|
|
407
210
|
*/
|
|
408
211
|
async validateToken(token) {
|
|
212
|
+
let response;
|
|
409
213
|
try {
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
};
|
|
413
|
-
if (token.startsWith("cookie:")) {
|
|
414
|
-
headers["Cookie"] = token.substring(7);
|
|
415
|
-
}
|
|
416
|
-
else {
|
|
417
|
-
headers["Authorization"] = `Bearer ${token}`;
|
|
418
|
-
}
|
|
419
|
-
const response = await fetch(`${this.config.baseUrl}/d2l/api/lp/1.45/users/whoami`, {
|
|
420
|
-
method: "GET",
|
|
421
|
-
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}` },
|
|
422
216
|
signal: AbortSignal.timeout(10000),
|
|
423
217
|
});
|
|
424
|
-
if (response.ok) {
|
|
425
|
-
log("DEBUG", "Token validation succeeded (whoami returned 200)");
|
|
426
|
-
return true;
|
|
427
|
-
}
|
|
428
|
-
log("DEBUG", `Token validation failed: HTTP ${response.status}`);
|
|
429
|
-
return false;
|
|
430
218
|
}
|
|
431
219
|
catch (error) {
|
|
432
|
-
|
|
433
|
-
return false;
|
|
220
|
+
throw new BrowserAuthTransportError("Token validation could not reach Brightspace.", { cause: error });
|
|
434
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}.`);
|
|
435
239
|
}
|
|
436
|
-
/**
|
|
437
|
-
* Set up passive network request listener to capture Bearer token.
|
|
438
|
-
* MUST be called BEFORE page.goto() to avoid race condition.
|
|
439
|
-
*/
|
|
440
|
-
setupTokenInterception(page, timeoutMs = 120000) {
|
|
441
|
-
return new Promise((resolve, reject) => {
|
|
442
|
-
const timeout = setTimeout(() => {
|
|
443
|
-
reject(new BrowserAuthError(`Token interception timed out after ${timeoutMs / 1000} seconds`, "token_interception"));
|
|
444
|
-
}, timeoutMs);
|
|
445
|
-
page.on("request", (request) => {
|
|
446
|
-
const url = request.url();
|
|
447
|
-
// Look for any request with a Bearer token
|
|
448
|
-
if (url.includes("/d2l/")) {
|
|
449
|
-
const authHeader = request.headers()["authorization"];
|
|
450
|
-
if (authHeader && authHeader.startsWith("Bearer ")) {
|
|
451
|
-
const token = authHeader.substring("Bearer ".length);
|
|
452
|
-
log("DEBUG", `Token captured from request to ${url}`);
|
|
453
|
-
clearTimeout(timeout);
|
|
454
|
-
resolve(token);
|
|
455
|
-
}
|
|
456
|
-
}
|
|
457
|
-
});
|
|
458
|
-
log("DEBUG", "Token interception listener registered");
|
|
459
|
-
});
|
|
460
|
-
}
|
|
461
|
-
/**
|
|
462
|
-
* Navigate to Brightspace and login if needed.
|
|
463
|
-
* Returns true if already authenticated (cookies valid), false if SSO login was performed.
|
|
464
|
-
*/
|
|
240
|
+
/** Restore first, and only attempt credentials after a real sign-in is needed. */
|
|
465
241
|
async navigateAndLogin(page) {
|
|
242
|
+
let navigationError;
|
|
243
|
+
let response;
|
|
466
244
|
try {
|
|
467
|
-
|
|
468
|
-
await page.goto(`${this.config.baseUrl}/d2l/home`, {
|
|
245
|
+
response = await page.goto(`${this.config.baseUrl}/d2l/home`, {
|
|
469
246
|
waitUntil: "domcontentloaded",
|
|
470
|
-
timeout:
|
|
247
|
+
timeout: INITIAL_NAVIGATION_TIMEOUT_MS,
|
|
471
248
|
});
|
|
472
|
-
if (await this.awaitSilentSSO(page)) {
|
|
473
|
-
log("INFO", "Already authenticated - skipping SSO login");
|
|
474
|
-
await this.settle(page);
|
|
475
|
-
return true;
|
|
476
|
-
}
|
|
477
|
-
// A credential login on Entra ends in a number match: two digits on the
|
|
478
|
-
// page that the human types into Authenticator. Headless has nowhere to
|
|
479
|
-
// show them, so the login would time out with the number sitting in a
|
|
480
|
-
// log file. Bail out and let authenticate() relaunch with a window.
|
|
481
|
-
if (needsVisibleBrowser({ launchedHeadless: this.launchedHeadless })) {
|
|
482
|
-
throw new HeadlessLoginRequiredError();
|
|
483
|
-
}
|
|
484
|
-
let loginSuccess;
|
|
485
|
-
if (this.ssoFlow.hasCredentials()) {
|
|
486
|
-
log("INFO", `Login required (stopped at ${page.url()}) - starting SSO flow`);
|
|
487
|
-
loginSuccess = await this.ssoFlow.login(page);
|
|
488
|
-
if (!loginSuccess) {
|
|
489
|
-
log("WARN", "Automated SSO flow failed or timed out. Falling back to manual login.");
|
|
490
|
-
log("INFO", "Please complete the login manually in the open browser window.");
|
|
491
|
-
loginSuccess = await this.ssoFlow.manualLogin(page);
|
|
492
|
-
}
|
|
493
|
-
}
|
|
494
|
-
else {
|
|
495
|
-
log("INFO", `Login required (stopped at ${page.url()}) - opening browser for manual login`);
|
|
496
|
-
loginSuccess = await this.ssoFlow.manualLogin(page);
|
|
497
|
-
}
|
|
498
|
-
if (!loginSuccess) {
|
|
499
|
-
throw new BrowserAuthError("Manual login flow failed", "manual_login");
|
|
500
|
-
}
|
|
501
|
-
await this.settle(page);
|
|
502
|
-
return false;
|
|
503
249
|
}
|
|
504
250
|
catch (error) {
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
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.");
|
|
508
275
|
}
|
|
509
|
-
}
|
|
510
|
-
/** Let the page finish loading, without letting a slow one fail the run. */
|
|
511
|
-
async settle(page) {
|
|
512
276
|
try {
|
|
513
|
-
await
|
|
277
|
+
if (!await this.ssoFlow.login(page)) {
|
|
278
|
+
throw new UnsupportedAuthenticationError("The identity provider could not complete headless sign-in.");
|
|
279
|
+
}
|
|
514
280
|
}
|
|
515
|
-
catch {
|
|
516
|
-
|
|
281
|
+
catch (error) {
|
|
282
|
+
if (error instanceof MfaApprovalError)
|
|
283
|
+
await this.cooldown.recordMfaFailure();
|
|
284
|
+
throw error;
|
|
517
285
|
}
|
|
286
|
+
if (!await this.hasLiveSession(page)) {
|
|
287
|
+
throw new BrowserAuthError("Sign-in did not produce a verified Brightspace session.", "session_validation");
|
|
288
|
+
}
|
|
289
|
+
return false;
|
|
518
290
|
}
|
|
519
291
|
/**
|
|
520
292
|
* Give the SSO chain a bounded window to finish with no human input.
|
|
@@ -522,20 +294,57 @@ export class BrowserAuth {
|
|
|
522
294
|
*/
|
|
523
295
|
async awaitSilentSSO(page) {
|
|
524
296
|
const deadline = Date.now() + SILENT_SSO_TIMEOUT_MS;
|
|
297
|
+
let accountHintAttempted = false;
|
|
298
|
+
let passwordPromptPolls = 0;
|
|
525
299
|
do {
|
|
526
300
|
if (await this.hasLiveSession(page))
|
|
527
301
|
return true;
|
|
528
|
-
//
|
|
529
|
-
//
|
|
530
|
-
if (await this.
|
|
531
|
-
|
|
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");
|
|
532
314
|
return false;
|
|
533
315
|
}
|
|
534
|
-
await this.
|
|
535
|
-
|
|
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
|
+
}
|
|
536
342
|
} while (Date.now() < deadline);
|
|
537
|
-
|
|
538
|
-
|
|
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);
|
|
539
348
|
}
|
|
540
349
|
/**
|
|
541
350
|
* A POSITIVE session check, never "the URL doesn't look like a login page".
|
|
@@ -545,6 +354,13 @@ export class BrowserAuth {
|
|
|
545
354
|
*/
|
|
546
355
|
async hasLiveSession(page) {
|
|
547
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;
|
|
548
364
|
const cookies = await page.context().cookies(this.config.baseUrl);
|
|
549
365
|
if (!cookies.some((c) => c.name === "d2lSessionVal" && Boolean(c.value))) {
|
|
550
366
|
return false;
|
|
@@ -554,8 +370,8 @@ export class BrowserAuth {
|
|
|
554
370
|
return d2l !== undefined && Boolean(d2l.LP);
|
|
555
371
|
});
|
|
556
372
|
}
|
|
557
|
-
catch {
|
|
558
|
-
|
|
373
|
+
catch (error) {
|
|
374
|
+
throw new BrowserAuthTransportError("The browser could not verify the saved Brightspace session. Saved state is preserved.", { cause: error });
|
|
559
375
|
}
|
|
560
376
|
}
|
|
561
377
|
/**
|
|
@@ -565,6 +381,11 @@ export class BrowserAuth {
|
|
|
565
381
|
async clickSilentSurfaces(page) {
|
|
566
382
|
const url = page.url();
|
|
567
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;
|
|
568
389
|
// Brightspace renders the campus buttons in a shadow DOM, which
|
|
569
390
|
// Playwright's selectors see through. A configured campus is matched by
|
|
570
391
|
// name; otherwise the selector's own SAML link is the only affordance
|
|
@@ -598,7 +419,20 @@ export class BrowserAuth {
|
|
|
598
419
|
}
|
|
599
420
|
/** Visibility without the actionability wait, so a poll keeps its rhythm. */
|
|
600
421
|
async isOnScreen(page, selector) {
|
|
601
|
-
|
|
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;
|
|
602
436
|
}
|
|
603
437
|
/**
|
|
604
438
|
* Try to extract Bearer token from D2L's localStorage.
|
|
@@ -693,7 +527,15 @@ export class BrowserAuth {
|
|
|
693
527
|
timeout: 15000,
|
|
694
528
|
});
|
|
695
529
|
}
|
|
696
|
-
|
|
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(() => {
|
|
697
539
|
const d2l = window.D2L;
|
|
698
540
|
try {
|
|
699
541
|
const lp = d2l?.LP;
|
|
@@ -702,7 +544,7 @@ export class BrowserAuth {
|
|
|
702
544
|
const xsrf = auth?.Xsrf;
|
|
703
545
|
const getToken = xsrf?.GetXsrfToken;
|
|
704
546
|
if (getToken) {
|
|
705
|
-
const value = getToken();
|
|
547
|
+
const value = getToken.call(xsrf);
|
|
706
548
|
if (value)
|
|
707
549
|
return value;
|
|
708
550
|
}
|
|
@@ -712,12 +554,15 @@ export class BrowserAuth {
|
|
|
712
554
|
}
|
|
713
555
|
const metaToken = document.querySelector('meta[name="d2l-xsrf-token"]');
|
|
714
556
|
return metaToken ? metaToken.getAttribute("content") : null;
|
|
557
|
+
}).catch((error) => {
|
|
558
|
+
log("DEBUG", "XSRF evaluation raced a page transition, retrying", error);
|
|
559
|
+
return null;
|
|
715
560
|
});
|
|
561
|
+
if (token)
|
|
562
|
+
return token;
|
|
563
|
+
await page.waitForTimeout(1000);
|
|
716
564
|
}
|
|
717
|
-
|
|
718
|
-
log("DEBUG", "XSRF-only extraction failed", error);
|
|
719
|
-
return null;
|
|
720
|
-
}
|
|
565
|
+
return null;
|
|
721
566
|
}
|
|
722
567
|
/**
|
|
723
568
|
* Try to extract XSRF/API token from D2L's JavaScript context.
|
|
@@ -746,7 +591,7 @@ export class BrowserAuth {
|
|
|
746
591
|
const xsrf = auth?.Xsrf;
|
|
747
592
|
const getToken = xsrf?.GetXsrfToken;
|
|
748
593
|
if (getToken)
|
|
749
|
-
return getToken();
|
|
594
|
+
return getToken.call(xsrf);
|
|
750
595
|
}
|
|
751
596
|
catch {
|
|
752
597
|
// Not available
|
|
@@ -804,209 +649,5 @@ export class BrowserAuth {
|
|
|
804
649
|
return null;
|
|
805
650
|
}
|
|
806
651
|
}
|
|
807
|
-
/**
|
|
808
|
-
* Load previously saved storage state (cookies + localStorage).
|
|
809
|
-
* Workaround for Playwright bug #36139: session cookies don't persist in persistent context.
|
|
810
|
-
*/
|
|
811
|
-
async loadStorageState(context) {
|
|
812
|
-
try {
|
|
813
|
-
const storageStatePath = path.join(this.config.sessionDir, "storage-state.json");
|
|
814
|
-
// Check if storage state file exists
|
|
815
|
-
let stats;
|
|
816
|
-
try {
|
|
817
|
-
stats = await fs.stat(storageStatePath);
|
|
818
|
-
}
|
|
819
|
-
catch {
|
|
820
|
-
log("DEBUG", "No existing storage state to load");
|
|
821
|
-
return;
|
|
822
|
-
}
|
|
823
|
-
// Skip loading stale cookies that would fake "already authenticated"
|
|
824
|
-
const ageMs = Date.now() - stats.mtimeMs;
|
|
825
|
-
const maxAgeMs = this.config.tokenTtl * 1000;
|
|
826
|
-
if (ageMs > maxAgeMs) {
|
|
827
|
-
log("INFO", `Storage state is ${Math.round(ageMs / 60000)}min old (TTL ${this.config.tokenTtl}s) — skipping stale cookies`);
|
|
828
|
-
return;
|
|
829
|
-
}
|
|
830
|
-
// Read storage state
|
|
831
|
-
const stateJson = await fs.readFile(storageStatePath, "utf-8");
|
|
832
|
-
const state = JSON.parse(stateJson);
|
|
833
|
-
// Restore cookies
|
|
834
|
-
if (state.cookies && state.cookies.length > 0) {
|
|
835
|
-
await context.addCookies(state.cookies);
|
|
836
|
-
log("INFO", `Restored ${state.cookies.length} cookies from storage state`);
|
|
837
|
-
}
|
|
838
|
-
// Restore localStorage for each origin
|
|
839
|
-
if (state.origins && state.origins.length > 0) {
|
|
840
|
-
for (const origin of state.origins) {
|
|
841
|
-
if (origin.localStorage && origin.localStorage.length > 0) {
|
|
842
|
-
let tempPage = null;
|
|
843
|
-
try {
|
|
844
|
-
// Create a temporary page to set localStorage
|
|
845
|
-
tempPage = await context.newPage();
|
|
846
|
-
await tempPage.goto(origin.origin, { timeout: 10000 });
|
|
847
|
-
// Set each localStorage item
|
|
848
|
-
await tempPage.evaluate((items) => {
|
|
849
|
-
for (const item of items) {
|
|
850
|
-
localStorage.setItem(item.name, item.value);
|
|
851
|
-
}
|
|
852
|
-
}, origin.localStorage);
|
|
853
|
-
log("INFO", `Restored ${origin.localStorage.length} localStorage items for ${origin.origin}`);
|
|
854
|
-
}
|
|
855
|
-
catch (originError) {
|
|
856
|
-
log("WARN", `Failed to restore localStorage for ${origin.origin}`, originError);
|
|
857
|
-
}
|
|
858
|
-
finally {
|
|
859
|
-
if (tempPage) {
|
|
860
|
-
try {
|
|
861
|
-
await tempPage.close();
|
|
862
|
-
}
|
|
863
|
-
catch {
|
|
864
|
-
// Page may already be closed
|
|
865
|
-
}
|
|
866
|
-
}
|
|
867
|
-
}
|
|
868
|
-
}
|
|
869
|
-
}
|
|
870
|
-
}
|
|
871
|
-
log("INFO", "Storage state restored successfully");
|
|
872
|
-
}
|
|
873
|
-
catch (error) {
|
|
874
|
-
log("WARN", "Failed to load storage state", error);
|
|
875
|
-
}
|
|
876
|
-
}
|
|
877
|
-
async saveStorageState(context) {
|
|
878
|
-
try {
|
|
879
|
-
const storageStatePath = path.join(this.config.sessionDir, "storage-state.json");
|
|
880
|
-
await context.storageState({ path: storageStatePath });
|
|
881
|
-
await fs.chmod(storageStatePath, 0o600);
|
|
882
|
-
log("DEBUG", `Storage state saved to ${storageStatePath}`);
|
|
883
|
-
}
|
|
884
|
-
catch (error) {
|
|
885
|
-
log("WARN", "Failed to save storage state", error);
|
|
886
|
-
}
|
|
887
|
-
}
|
|
888
|
-
/**
|
|
889
|
-
* Launch browser with retry logic.
|
|
890
|
-
* Windows is prone to 180s launch timeouts (Playwright issue #22117) caused by
|
|
891
|
-
* lingering Chromium processes, antivirus interference, or resource contention.
|
|
892
|
-
* On timeout, we clear lock files and retry once.
|
|
893
|
-
*/
|
|
894
|
-
async launchBrowserWithRetry(browserDataDir, options) {
|
|
895
|
-
// The only place the browser is needed, so the only place it is loaded.
|
|
896
|
-
const { chromium } = await import("playwright");
|
|
897
|
-
// Validate lock files before every launch attempt
|
|
898
|
-
await this.validateAndClearLockFiles(browserDataDir);
|
|
899
|
-
try {
|
|
900
|
-
// Wrap in Promise.race so a hung launch (e.g. stale SingletonLock
|
|
901
|
-
// that wasn't caught) still falls into the retry path
|
|
902
|
-
const launchPromise = chromium.launchPersistentContext(browserDataDir, options);
|
|
903
|
-
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error("Timeout: browser launch hung")), options.timeout));
|
|
904
|
-
return await Promise.race([launchPromise, timeoutPromise]);
|
|
905
|
-
}
|
|
906
|
-
catch (error) {
|
|
907
|
-
const errMsg = error instanceof Error ? error.message : String(error);
|
|
908
|
-
const isTimeout = errMsg.includes("Timeout") || errMsg.includes("timeout") || errMsg.includes("hung");
|
|
909
|
-
// macOS: Chrome for Testing died during launch with SIGTRAP / browser
|
|
910
|
-
// closed. The profile is likely corrupted from prior crashes. Quarantine
|
|
911
|
-
// it, reapply the crash guards, and retry with a fresh profile.
|
|
912
|
-
// See issue #10.
|
|
913
|
-
const isMacBrowserCrash = process.platform === "darwin" &&
|
|
914
|
-
(errMsg.includes("Target page, context or browser has been closed") ||
|
|
915
|
-
errMsg.includes("SIGTRAP") ||
|
|
916
|
-
errMsg.includes("did exit"));
|
|
917
|
-
if (isMacBrowserCrash) {
|
|
918
|
-
log("WARN", "Chrome for Testing crashed during launch — quarantining profile and retrying");
|
|
919
|
-
await this.quarantineBrowserDataDir(browserDataDir);
|
|
920
|
-
await BrowserAuth.applyMacOSCrashGuard();
|
|
921
|
-
return await chromium.launchPersistentContext(browserDataDir, {
|
|
922
|
-
...options,
|
|
923
|
-
timeout: 90000,
|
|
924
|
-
});
|
|
925
|
-
}
|
|
926
|
-
if (isTimeout) {
|
|
927
|
-
log("WARN", "Browser launch timed out — clearing lock files and retrying");
|
|
928
|
-
await this.validateAndClearLockFiles(browserDataDir);
|
|
929
|
-
return await chromium.launchPersistentContext(browserDataDir, {
|
|
930
|
-
...options,
|
|
931
|
-
timeout: 90000,
|
|
932
|
-
});
|
|
933
|
-
}
|
|
934
|
-
throw error;
|
|
935
|
-
}
|
|
936
|
-
}
|
|
937
|
-
/**
|
|
938
|
-
* Validate and remove stale Chromium lock files from the browser data directory.
|
|
939
|
-
* Playwright's persistent context uses Chromium's SingletonLock mechanism.
|
|
940
|
-
* If the browser is killed unexpectedly (antivirus, force close, crash),
|
|
941
|
-
* these lock files persist and block all future launch attempts.
|
|
942
|
-
*
|
|
943
|
-
* For SingletonLock (a symlink whose target is "hostname-pid"), we check
|
|
944
|
-
* whether the owning process is still alive before removing it. This avoids
|
|
945
|
-
* deleting a lock held by a legitimate running instance.
|
|
946
|
-
*/
|
|
947
|
-
async validateAndClearLockFiles(browserDataDir) {
|
|
948
|
-
// Ensure the directory exists before scanning
|
|
949
|
-
try {
|
|
950
|
-
await fs.access(browserDataDir);
|
|
951
|
-
}
|
|
952
|
-
catch {
|
|
953
|
-
return; // Directory doesn't exist yet, nothing to clean
|
|
954
|
-
}
|
|
955
|
-
const lockFiles = ["SingletonLock", "SingletonCookie", "SingletonSocket"];
|
|
956
|
-
for (const lockFile of lockFiles) {
|
|
957
|
-
const lockPath = path.join(browserDataDir, lockFile);
|
|
958
|
-
try {
|
|
959
|
-
const stat = await fs.lstat(lockPath);
|
|
960
|
-
if (stat.isSymbolicLink() && lockFile === "SingletonLock") {
|
|
961
|
-
// SingletonLock is a symlink with target "hostname-pid"
|
|
962
|
-
const target = await fs.readlink(lockPath);
|
|
963
|
-
const dashIdx = target.lastIndexOf("-");
|
|
964
|
-
if (dashIdx > 0) {
|
|
965
|
-
const lockHostname = target.substring(0, dashIdx);
|
|
966
|
-
const lockPid = parseInt(target.substring(dashIdx + 1), 10);
|
|
967
|
-
if (lockHostname !== os.hostname()) {
|
|
968
|
-
// Lock from a different machine (e.g. NFS home dir, or stale after network change)
|
|
969
|
-
log("WARN", `Removing SingletonLock from different host (${lockHostname} vs ${os.hostname()})`);
|
|
970
|
-
await fs.unlink(lockPath);
|
|
971
|
-
continue;
|
|
972
|
-
}
|
|
973
|
-
if (!isNaN(lockPid)) {
|
|
974
|
-
try {
|
|
975
|
-
process.kill(lockPid, 0); // Signal 0 checks if process exists
|
|
976
|
-
// Process is alive, leave the lock alone
|
|
977
|
-
log("DEBUG", `SingletonLock held by live process ${lockPid}, skipping`);
|
|
978
|
-
continue;
|
|
979
|
-
}
|
|
980
|
-
catch (killErr) {
|
|
981
|
-
const code = killErr.code;
|
|
982
|
-
if (code === "ESRCH") {
|
|
983
|
-
// Process is dead, safe to remove
|
|
984
|
-
log("WARN", `Removing SingletonLock from dead process ${lockPid}`);
|
|
985
|
-
await fs.unlink(lockPath);
|
|
986
|
-
continue;
|
|
987
|
-
}
|
|
988
|
-
if (code === "EPERM") {
|
|
989
|
-
// Process exists but we lack permissions, leave it alone
|
|
990
|
-
log("DEBUG", `SingletonLock held by process ${lockPid} (EPERM), skipping`);
|
|
991
|
-
continue;
|
|
992
|
-
}
|
|
993
|
-
}
|
|
994
|
-
}
|
|
995
|
-
}
|
|
996
|
-
// Could not parse target, remove as a safety measure
|
|
997
|
-
log("WARN", `Removing unparseable SingletonLock: ${target}`);
|
|
998
|
-
await fs.unlink(lockPath);
|
|
999
|
-
}
|
|
1000
|
-
else {
|
|
1001
|
-
// Regular file (SingletonCookie, SingletonSocket) or unknown symlink
|
|
1002
|
-
await fs.unlink(lockPath);
|
|
1003
|
-
log("WARN", `Removed stale lock file: ${lockFile}`);
|
|
1004
|
-
}
|
|
1005
|
-
}
|
|
1006
|
-
catch {
|
|
1007
|
-
// File doesn't exist, expected in normal operation
|
|
1008
|
-
}
|
|
1009
|
-
}
|
|
1010
|
-
}
|
|
1011
652
|
}
|
|
1012
653
|
//# sourceMappingURL=browser-auth.js.map
|