brightspace-mcp-server 3.7.2 → 3.7.3
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 +2 -0
- package/build/auth/auth-lock.js +83 -10
- package/build/auth/auth-runner.js +1 -1
- package/build/auth/browser-auth.js +1 -1
- package/build/auth/duo-mfa.js +6 -3
- package/build/auth/purdue-sso.js +133 -3
- package/build/tools/tool-helpers.js +2 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -105,6 +105,8 @@ Run it from your home folder. On macOS, a terminal that lacks Files and Folders
|
|
|
105
105
|
|
|
106
106
|
**MFA at Purdue** commonly uses Microsoft Authenticator number matching (some schools use Duo instead). When a sign-in needs it, the tool call itself returns quickly with the number to enter, rather than sitting silent for up to five minutes — approve it on your phone, then call the tool again; sign-in finishes in the background in the meantime. Google Authenticator and other one-time-code apps work too, with no setting to change: run the auth command above in a terminal and it prompts for the code when your provider asks for one. Pick the visible-browser option during setup only if your identity provider needs interaction the server cannot drive. The MCP also sends authentication progress as logging notifications to clients that display them, useful if you don't see the number in the tool response for some reason.
|
|
107
107
|
|
|
108
|
+
The number in that response can go stale if the request times out or you tap Deny before approving it: the server asks Microsoft for another one and the next tool call reports the new number, so always enter whatever number the most recent response shows. If a background sign-in gets stuck, running the auth command above in a terminal takes it over immediately instead of waiting for it to finish or time out.
|
|
109
|
+
|
|
108
110
|
**On a Duo tenant**, if Duo asks "Is this your device?" before it will send a push, automatic sign-in answers **yes** so the push can go out at all — a headless run has nobody to click it otherwise. That also makes Duo remember the device, which skips its own device check on later logins from this machine. Don't run automatic sign-in on a shared or public computer if you'd rather Duo keep asking. Setting `D2L_DUO_PASSCODE` to any value switches from waiting for a push to typing a code from Duo Mobile's passcode option instead.
|
|
109
111
|
|
|
110
112
|
## What You Can Ask About
|
package/build/auth/auth-lock.js
CHANGED
|
@@ -48,15 +48,20 @@ export class AuthenticationInProgressError extends Error {
|
|
|
48
48
|
async function readOwner(lockPath) {
|
|
49
49
|
try {
|
|
50
50
|
const owner = JSON.parse(await fs.readFile(path.join(lockPath, "owner.json"), "utf8"));
|
|
51
|
-
if (Number.isSafeInteger(owner.pid) && owner.pid > 0 && typeof owner.host === "string" && typeof owner.nonce === "string")
|
|
52
|
-
|
|
51
|
+
if (Number.isSafeInteger(owner.pid) && owner.pid > 0 && typeof owner.host === "string" && typeof owner.nonce === "string") {
|
|
52
|
+
// A lock file written before `mode` existed, or one that carries an
|
|
53
|
+
// unrecognized value, is treated as "explicit" — the safer default,
|
|
54
|
+
// since only an "automatic" owner is ever eligible to be taken over.
|
|
55
|
+
const mode = owner.mode === "automatic" ? "automatic" : "explicit";
|
|
56
|
+
return { pid: owner.pid, host: owner.host, nonce: owner.nonce, mode };
|
|
57
|
+
}
|
|
53
58
|
}
|
|
54
59
|
catch {
|
|
55
60
|
// An incomplete or unknown lock is not proof that its owner is gone.
|
|
56
61
|
}
|
|
57
62
|
return undefined;
|
|
58
63
|
}
|
|
59
|
-
function
|
|
64
|
+
function isDeadImpl(owner) {
|
|
60
65
|
// Session directories are local-only. DHCP can change the host's name
|
|
61
66
|
// without changing this process table, so only PID liveness decides.
|
|
62
67
|
try {
|
|
@@ -67,6 +72,20 @@ function isDead(owner) {
|
|
|
67
72
|
return error.code === "ESRCH";
|
|
68
73
|
}
|
|
69
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* Process operations behind a swappable seam. Production code always goes
|
|
77
|
+
* through this object with its real implementations; a test that needs a
|
|
78
|
+
* "live" owner it can safely terminate (without spawning or signaling a real
|
|
79
|
+
* process) replaces `kill`/`isDead` here instead. This file is intentionally
|
|
80
|
+
* free of relative imports — see tests/auth/auth-lock.test.ts, which loads it
|
|
81
|
+
* standalone via a data: URL for its real-process tests, and a relative
|
|
82
|
+
* import cannot be resolved from that base — so this seam is a plain
|
|
83
|
+
* exported object rather than an injected logger or similar dependency.
|
|
84
|
+
*/
|
|
85
|
+
export const lockOps = {
|
|
86
|
+
kill: (pid, signal) => { process.kill(pid, signal); },
|
|
87
|
+
isDead: isDeadImpl,
|
|
88
|
+
};
|
|
70
89
|
/** Remove only our fixed metadata names, after the directory was retired. */
|
|
71
90
|
async function cleanRetired(lockPath) {
|
|
72
91
|
await fs.unlink(path.join(lockPath, "owner.json")).catch(() => { });
|
|
@@ -103,16 +122,56 @@ class Lease {
|
|
|
103
122
|
await cleanRetired(this.directory);
|
|
104
123
|
}
|
|
105
124
|
}
|
|
125
|
+
/**
|
|
126
|
+
* How long an explicit run waits, after signaling a live automatic owner,
|
|
127
|
+
* for that owner to actually release the lock (either by dying outright or
|
|
128
|
+
* by finishing its own cleanup and renaming the directory away) before
|
|
129
|
+
* retrying acquisition.
|
|
130
|
+
*/
|
|
131
|
+
const TAKEOVER_WAIT_MS = 10_000;
|
|
132
|
+
const TAKEOVER_POLL_MS = 100;
|
|
133
|
+
/**
|
|
134
|
+
* SIGTERM a live automatic owner and wait for it to actually give up the
|
|
135
|
+
* lock. browser-auth.ts's `closeOnSignal` handles SIGTERM by closing the
|
|
136
|
+
* browser, which makes the automatic run's in-flight MFA loop throw; its
|
|
137
|
+
* `authenticate()` then runs its `finally` and releases the lock the normal
|
|
138
|
+
* way (the directory is renamed away). If the child instead dies outright
|
|
139
|
+
* (already-registered signal handler races the browser launch, or the
|
|
140
|
+
* process is killed some other way), `isDead` catches that instead. Either
|
|
141
|
+
* way this resolves once the lock is free for `acquireLease` to retry, or
|
|
142
|
+
* once TAKEOVER_WAIT_MS has passed without either happening.
|
|
143
|
+
*/
|
|
144
|
+
async function waitForTakeover(lockPath, stale) {
|
|
145
|
+
try {
|
|
146
|
+
lockOps.kill(stale.pid, "SIGTERM");
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
// The owner exited between the liveness check and the signal. That is the
|
|
150
|
+
// outcome a takeover wants; the wait below sees the dead PID and returns.
|
|
151
|
+
if (error.code !== "ESRCH")
|
|
152
|
+
throw error;
|
|
153
|
+
}
|
|
154
|
+
const deadline = Date.now() + TAKEOVER_WAIT_MS;
|
|
155
|
+
while (Date.now() < deadline) {
|
|
156
|
+
if (lockOps.isDead(stale))
|
|
157
|
+
return;
|
|
158
|
+
const current = await readOwner(lockPath);
|
|
159
|
+
if (!current || current.nonce !== stale.nonce)
|
|
160
|
+
return;
|
|
161
|
+
await delay(TAKEOVER_POLL_MS);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
106
164
|
/** Acquire a process-shared lock without waiting or relying on its age. */
|
|
107
|
-
export async function acquireProcessLock(lockPath) {
|
|
165
|
+
export async function acquireProcessLock(lockPath, options = {}) {
|
|
108
166
|
await fs.mkdir(path.dirname(lockPath), { recursive: true, mode: 0o700 });
|
|
109
|
-
const
|
|
167
|
+
const mode = options.mode ?? "explicit";
|
|
168
|
+
const lease = await acquireLease(lockPath, 0, mode);
|
|
110
169
|
return () => lease.release();
|
|
111
170
|
}
|
|
112
|
-
async function acquireLease(lockPath, depth) {
|
|
171
|
+
async function acquireLease(lockPath, depth, mode) {
|
|
113
172
|
if (depth > 16)
|
|
114
173
|
throw new AuthenticationInProgressError();
|
|
115
|
-
const owner = { pid: process.pid, host: hostname(), nonce: randomUUID() };
|
|
174
|
+
const owner = { pid: process.pid, host: hostname(), nonce: randomUUID(), mode };
|
|
116
175
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
117
176
|
try {
|
|
118
177
|
await fs.mkdir(lockPath, { mode: 0o700 });
|
|
@@ -132,15 +191,29 @@ async function acquireLease(lockPath, depth) {
|
|
|
132
191
|
throw error;
|
|
133
192
|
}
|
|
134
193
|
const stale = await readOwner(lockPath);
|
|
135
|
-
if (!stale
|
|
194
|
+
if (!stale)
|
|
136
195
|
throw new AuthenticationInProgressError();
|
|
196
|
+
if (!lockOps.isDead(stale)) {
|
|
197
|
+
// A live owner is not stale — the existing behavior for two contending
|
|
198
|
+
// explicit runs, or an automatic run finding anything alive at all.
|
|
199
|
+
// The one exception: an explicit run may take over a live *automatic*
|
|
200
|
+
// one, since a background sign-in nobody is watching should not block
|
|
201
|
+
// someone who just sat down to fix it in a terminal.
|
|
202
|
+
if (mode !== "explicit" || stale.mode !== "automatic")
|
|
203
|
+
throw new AuthenticationInProgressError();
|
|
204
|
+
// No import of the shared logger here — see lockOps's doc comment on
|
|
205
|
+
// why this file stays free of relative imports.
|
|
206
|
+
console.error(`[${new Date().toISOString()}] [WARN] Took over a background sign-in (pid ${stale.pid}) for an explicit auth run`);
|
|
207
|
+
await waitForTakeover(lockPath, stale);
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
137
210
|
// Recovery gets its own process lock within the stale directory. If a
|
|
138
211
|
// recovering process crashes, the same dead-owner rules recover its lock.
|
|
139
212
|
// Keep this claim in the directory during rename to serialize contenders.
|
|
140
|
-
const claim = await acquireLease(path.join(lockPath, "reclaim.lock"), depth + 1);
|
|
213
|
+
const claim = await acquireLease(path.join(lockPath, "reclaim.lock"), depth + 1, mode);
|
|
141
214
|
try {
|
|
142
215
|
const current = await readOwner(lockPath);
|
|
143
|
-
if (current?.nonce !== stale.nonce || !isDead(current))
|
|
216
|
+
if (current?.nonce !== stale.nonce || !lockOps.isDead(current))
|
|
144
217
|
throw new AuthenticationInProgressError();
|
|
145
218
|
const retired = `${lockPath}.stale.${owner.nonce}`;
|
|
146
219
|
// A rename blocked by contention means another contender is inside this
|
|
@@ -408,7 +408,7 @@ export class AuthRunner {
|
|
|
408
408
|
}
|
|
409
409
|
else {
|
|
410
410
|
const failures = {
|
|
411
|
-
2: ["busy",
|
|
411
|
+
2: ["busy", `Authentication already in progress in another process. Complete that attempt, then retry, or run \`${AUTH_COMMAND}\` in a terminal to take over that background attempt.`],
|
|
412
412
|
3: ["cooldown", `Automatic MFA is paused after an unsuccessful attempt. Run ${AUTH_COMMAND} to retry immediately.`],
|
|
413
413
|
4: ["unsupported", "This identity provider cannot complete headless authentication. See the authentication logs."],
|
|
414
414
|
5: ["secureStorage", "The native credential store is unavailable or locked. Unlock it and retry."],
|
|
@@ -68,7 +68,7 @@ export class BrowserAuth {
|
|
|
68
68
|
}
|
|
69
69
|
}
|
|
70
70
|
async authenticate(options = {}) {
|
|
71
|
-
const release = await acquireProcessLock(path.join(this.config.sessionDir, ".auth.lock"));
|
|
71
|
+
const release = await acquireProcessLock(path.join(this.config.sessionDir, ".auth.lock"), { mode: options.automatic ? "automatic" : "explicit" });
|
|
72
72
|
try {
|
|
73
73
|
// Even a cookie-only SAML redirect can issue an MFA push. Suppress all
|
|
74
74
|
// automatic browser attempts during cooldown; HTTP token refresh runs
|
package/build/auth/duo-mfa.js
CHANGED
|
@@ -62,6 +62,8 @@ export class DuoMfaHandler {
|
|
|
62
62
|
passcodeChoiceClicked = false;
|
|
63
63
|
/** True once onMfaChallenge has been told about this login, code or not. */
|
|
64
64
|
announcedToCaller = false;
|
|
65
|
+
/** The last code actually handed to onMfaChallenge. See purdue-sso.ts's lastAnnouncedNumber for why this is tracked separately from verificationCodeAnnounced. */
|
|
66
|
+
lastAnnouncedCode = null;
|
|
65
67
|
constructor(options) {
|
|
66
68
|
this.options = options;
|
|
67
69
|
}
|
|
@@ -147,14 +149,15 @@ export class DuoMfaHandler {
|
|
|
147
149
|
this.approvalAnnounced = true;
|
|
148
150
|
log("WARN", "Waiting up to 5 minutes for Duo MFA approval on your device.");
|
|
149
151
|
this.options.onMfaChallenge?.(verificationCode);
|
|
150
|
-
|
|
151
|
-
|
|
152
|
+
this.announcedToCaller = true;
|
|
153
|
+
this.lastAnnouncedCode = verificationCode;
|
|
152
154
|
}
|
|
153
155
|
if (verificationCode && verificationCode !== this.verificationCodeAnnounced) {
|
|
154
156
|
this.verificationCodeAnnounced = verificationCode;
|
|
155
157
|
log("WARN", `Duo verification code: ${verificationCode}. Enter it in Duo Mobile.`);
|
|
156
|
-
if (!this.announcedToCaller) {
|
|
158
|
+
if (!this.announcedToCaller || verificationCode !== this.lastAnnouncedCode) {
|
|
157
159
|
this.announcedToCaller = true;
|
|
160
|
+
this.lastAnnouncedCode = verificationCode;
|
|
158
161
|
this.options.onMfaChallenge?.(verificationCode);
|
|
159
162
|
}
|
|
160
163
|
}
|
package/build/auth/purdue-sso.js
CHANGED
|
@@ -28,6 +28,48 @@ const MFA_CODE_SUBMIT_SELECTORS = ["#idSubmit_SAOTCC_Continue", "#idSIButton9"];
|
|
|
28
28
|
const NUMBER_MATCH_POLL_MS = 2000;
|
|
29
29
|
/** A person has to find their phone, unlock it, and read a prompt. */
|
|
30
30
|
const MFA_TIMEOUT_MS = 5 * 60 * 1000;
|
|
31
|
+
/**
|
|
32
|
+
* Entra's number-match request itself expires (typically ~1-2 minutes) well
|
|
33
|
+
* before the 5-minute MFA_TIMEOUT_MS this flow allows overall, or the user
|
|
34
|
+
* can tap Deny. Either way Entra stops showing the number and offers a way to
|
|
35
|
+
* send another request; without asking for one this flow just polls a dead
|
|
36
|
+
* request until MFA_TIMEOUT_MS. Bounded so a tenant that never clears this
|
|
37
|
+
* screen still hits the ordinary timeout instead of resending forever.
|
|
38
|
+
*/
|
|
39
|
+
const MAX_RESENDS = 2;
|
|
40
|
+
/**
|
|
41
|
+
* EXPECTED, not verified against a live tenant from here: Entra's "Send
|
|
42
|
+
* another request" control on the number-match timeout/denied view.
|
|
43
|
+
* Matched by id first because it is far more specific than the text fallback
|
|
44
|
+
* below. Entra's own title ids for this view — `#idDiv_SAASTO_Title` for a
|
|
45
|
+
* timeout, `#idDiv_SAASDS_Title` for a denial — are not matched directly
|
|
46
|
+
* since this id is the more stable target, but are left here as a breadcrumb
|
|
47
|
+
* for whoever next has a live tenant to confirm these against.
|
|
48
|
+
*/
|
|
49
|
+
const RESEND_ID_SELECTOR = "#idA_SAASTO_Resend";
|
|
50
|
+
/**
|
|
51
|
+
* EXPECTED, not observed: a fallback for when Entra renames the id above.
|
|
52
|
+
* Any visible control whose text reads as a resend action, scoped to the
|
|
53
|
+
* sign-in surface (never the whole document, which can contain unrelated
|
|
54
|
+
* matches).
|
|
55
|
+
*/
|
|
56
|
+
const RESEND_TEXT_PATTERN = /send another request|resend notification|try again|send again/i;
|
|
57
|
+
/**
|
|
58
|
+
* Never click a control offering to switch verification methods — that
|
|
59
|
+
* abandons number-match for something this headless flow cannot complete.
|
|
60
|
+
*/
|
|
61
|
+
const RESEND_SWITCH_METHOD_PATTERN = /can.t use|sign in another way|different verification|other ways/i;
|
|
62
|
+
/** Where the text-based resend fallback is allowed to look. */
|
|
63
|
+
const RESEND_SCOPE_SELECTORS = ["#lightbox", "form"];
|
|
64
|
+
/** Log once, not on every poll, when no resend control turns up after a number vanishes. */
|
|
65
|
+
const RESEND_NOT_FOUND_WARN_MS = 30_000;
|
|
66
|
+
/**
|
|
67
|
+
* Don't click the resend control again on the immediately following poll —
|
|
68
|
+
* give Entra a full cycle to re-render before looking again. Two poll
|
|
69
|
+
* intervals rather than one so ordinary per-poll overhead can never make the
|
|
70
|
+
* elapsed time creep past a single interval and defeat the guard.
|
|
71
|
+
*/
|
|
72
|
+
const RESEND_CLICK_GUARD_MS = NUMBER_MATCH_POLL_MS * 2;
|
|
31
73
|
/** Microsoft expects Purdue's full sign-in name, while setup also accepts a career account. */
|
|
32
74
|
function signInName(username, baseUrl) {
|
|
33
75
|
const isPurdue = baseUrl && new URL(baseUrl).hostname.toLowerCase() === "purdue.brightspace.com";
|
|
@@ -229,6 +271,20 @@ export class PurdueSSOFlow {
|
|
|
229
271
|
let announced = null;
|
|
230
272
|
/** True once onMfaChallenge has been told about this login, number or not. */
|
|
231
273
|
let announcedToCaller = false;
|
|
274
|
+
/**
|
|
275
|
+
* The last number actually handed to onMfaChallenge. Distinct from
|
|
276
|
+
* `announced` (which only dedupes the WARN log): once the caller has been
|
|
277
|
+
* told about a number, a later DIFFERENT one — including a numberless
|
|
278
|
+
* announcement's first number — must reach onMfaChallenge again, even
|
|
279
|
+
* though `announced` already fires the log every time it changes.
|
|
280
|
+
*/
|
|
281
|
+
let lastAnnouncedNumber = null;
|
|
282
|
+
/** True once a number has appeared at least once this login (Fix 2 below only reacts after one has). */
|
|
283
|
+
let sawNumber = false;
|
|
284
|
+
let resendCount = 0;
|
|
285
|
+
let resendLastClickAt = null;
|
|
286
|
+
let numberVanishedAt = null;
|
|
287
|
+
let resendNotFoundWarned = false;
|
|
232
288
|
try {
|
|
233
289
|
while (Date.now() < deadline) {
|
|
234
290
|
if (await this.duoMfa.handle(page))
|
|
@@ -236,6 +292,8 @@ export class PurdueSSOFlow {
|
|
|
236
292
|
if (await this.submitMfaCode(page))
|
|
237
293
|
challenged = true;
|
|
238
294
|
const number = await this.readNumberMatch(page);
|
|
295
|
+
if (number)
|
|
296
|
+
sawNumber = true;
|
|
239
297
|
const challengeVisible = number !== null ||
|
|
240
298
|
await page.locator("#idDiv_SAOTCAS_Title").first().isVisible().catch(() => false) ||
|
|
241
299
|
await page.locator("#idDiv_SAOTCC_Title").first().isVisible().catch(() => false);
|
|
@@ -243,21 +301,46 @@ export class PurdueSSOFlow {
|
|
|
243
301
|
challenged = true;
|
|
244
302
|
log("WARN", "Waiting up to 5 minutes for Microsoft MFA approval on your device.");
|
|
245
303
|
this.config.onMfaChallenge?.(number);
|
|
246
|
-
|
|
247
|
-
|
|
304
|
+
announcedToCaller = true;
|
|
305
|
+
lastAnnouncedNumber = number;
|
|
248
306
|
}
|
|
249
307
|
if (number && number !== announced) {
|
|
250
308
|
announced = number;
|
|
251
309
|
log("WARN", `Number match: ${number}. Enter it in Microsoft Authenticator.`);
|
|
252
|
-
if (!announcedToCaller) {
|
|
310
|
+
if (!announcedToCaller || number !== lastAnnouncedNumber) {
|
|
253
311
|
announcedToCaller = true;
|
|
312
|
+
lastAnnouncedNumber = number;
|
|
254
313
|
this.config.onMfaChallenge?.(number);
|
|
255
314
|
}
|
|
256
315
|
}
|
|
316
|
+
if (number) {
|
|
317
|
+
numberVanishedAt = null;
|
|
318
|
+
resendNotFoundWarned = false;
|
|
319
|
+
}
|
|
257
320
|
if (await this.isAuthenticated(page)) {
|
|
258
321
|
log("INFO", "Login successful - verified Brightspace home");
|
|
259
322
|
return;
|
|
260
323
|
}
|
|
324
|
+
// Fix 2: Entra's number-match request itself times out (or the user
|
|
325
|
+
// taps Deny) well before the 5-minute budget above. Once that has
|
|
326
|
+
// happened, ask Entra for another one instead of polling a dead
|
|
327
|
+
// request until MFA_TIMEOUT_MS; readNumberMatch on the next poll
|
|
328
|
+
// picks up the fresh number, and the re-announce logic above tells
|
|
329
|
+
// the caller about it.
|
|
330
|
+
if (sawNumber && number === null && new URL(page.url()).hostname === "login.microsoftonline.com") {
|
|
331
|
+
if (numberVanishedAt === null)
|
|
332
|
+
numberVanishedAt = Date.now();
|
|
333
|
+
resendNotFoundWarned = await this.tryResendNumberMatch(page, {
|
|
334
|
+
resendCount,
|
|
335
|
+
resendLastClickAt,
|
|
336
|
+
numberVanishedAt,
|
|
337
|
+
resendNotFoundWarned,
|
|
338
|
+
onResend: (clickedAt) => {
|
|
339
|
+
resendCount += 1;
|
|
340
|
+
resendLastClickAt = clickedAt;
|
|
341
|
+
},
|
|
342
|
+
});
|
|
343
|
+
}
|
|
261
344
|
await this.clickProvenKmsi(page);
|
|
262
345
|
// The federated-domain trust prompt arrives after the IdP succeeds, so
|
|
263
346
|
// it has to be caught by this loop rather than by enterCredentials.
|
|
@@ -276,6 +359,53 @@ export class PurdueSSOFlow {
|
|
|
276
359
|
throw new MfaApprovalError(undefined, announced ?? undefined);
|
|
277
360
|
throw new UnsupportedAuthenticationError("Sign-in did not reach a supported MFA challenge or Brightspace within 5 minutes.");
|
|
278
361
|
}
|
|
362
|
+
/**
|
|
363
|
+
* Look for Entra's "send another request" affordance and click it, at most
|
|
364
|
+
* MAX_RESENDS times per login and never twice in immediate succession.
|
|
365
|
+
* Returns the (possibly updated) resendNotFoundWarned flag for the caller
|
|
366
|
+
* to carry into the next poll.
|
|
367
|
+
*/
|
|
368
|
+
async tryResendNumberMatch(page, state) {
|
|
369
|
+
if (state.resendCount >= MAX_RESENDS)
|
|
370
|
+
return state.resendNotFoundWarned;
|
|
371
|
+
if (state.resendLastClickAt !== null && Date.now() - state.resendLastClickAt < RESEND_CLICK_GUARD_MS) {
|
|
372
|
+
return state.resendNotFoundWarned;
|
|
373
|
+
}
|
|
374
|
+
const control = await this.findResendControl(page);
|
|
375
|
+
if (!control) {
|
|
376
|
+
if (!state.resendNotFoundWarned && Date.now() - state.numberVanishedAt >= RESEND_NOT_FOUND_WARN_MS) {
|
|
377
|
+
log("WARN", "Number-match request appears to have expired or been denied, but no \"send another request\" control was found; continuing to wait.");
|
|
378
|
+
return true;
|
|
379
|
+
}
|
|
380
|
+
return state.resendNotFoundWarned;
|
|
381
|
+
}
|
|
382
|
+
const clickedAt = Date.now();
|
|
383
|
+
state.onResend(clickedAt);
|
|
384
|
+
log("WARN", `Number-match request expired; sent another request (${state.resendCount + 1} of ${MAX_RESENDS}).`);
|
|
385
|
+
await control.click().catch(() => { });
|
|
386
|
+
return state.resendNotFoundWarned;
|
|
387
|
+
}
|
|
388
|
+
/** Entra's resend control, matched by id first, then by a scoped text fallback. See the selector constants' own comments. */
|
|
389
|
+
async findResendControl(page) {
|
|
390
|
+
const byId = page.locator(RESEND_ID_SELECTOR).first();
|
|
391
|
+
if (await byId.isVisible().catch(() => false))
|
|
392
|
+
return byId;
|
|
393
|
+
for (const scopeSelector of RESEND_SCOPE_SELECTORS) {
|
|
394
|
+
const scope = page.locator(scopeSelector).first();
|
|
395
|
+
if (!await scope.isVisible().catch(() => false))
|
|
396
|
+
continue;
|
|
397
|
+
const candidates = await scope.getByText(RESEND_TEXT_PATTERN).all().catch(() => []);
|
|
398
|
+
for (const candidate of candidates) {
|
|
399
|
+
if (!await candidate.isVisible().catch(() => false))
|
|
400
|
+
continue;
|
|
401
|
+
const text = (await candidate.textContent().catch(() => null)) ?? "";
|
|
402
|
+
if (RESEND_SWITCH_METHOD_PATTERN.test(text))
|
|
403
|
+
continue;
|
|
404
|
+
return candidate;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
return null;
|
|
408
|
+
}
|
|
279
409
|
async submitMfaCode(page) {
|
|
280
410
|
const input = await this.firstVisible(page, MFA_CODE_SELECTORS);
|
|
281
411
|
if (!input)
|
|
@@ -57,7 +57,8 @@ export function errorResponse(message) {
|
|
|
57
57
|
* words in the response. The full error still goes to the log.
|
|
58
58
|
*/
|
|
59
59
|
const AUTH_FAILURE_GUIDANCE = {
|
|
60
|
-
busy: "A sign-in is already running in another process. Let it finish, then try again
|
|
60
|
+
busy: "A sign-in is already running in another process. Let it finish, then try again, " +
|
|
61
|
+
`or run \`${AUTH_COMMAND}\` in a terminal to take over that background attempt.`,
|
|
61
62
|
cooldown: "Automatic sign-in is paused because an MFA prompt went unanswered. " +
|
|
62
63
|
`Run \`${AUTH_COMMAND}\` in a terminal (from your home folder) to retry now and see the number to enter.`,
|
|
63
64
|
unsupported: "This login needs something your AI client cannot supply, usually a code from an "
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brightspace-mcp-server",
|
|
3
|
-
"version": "3.7.
|
|
3
|
+
"version": "3.7.3",
|
|
4
4
|
"mcpName": "io.github.rohanmuppa/brightspace",
|
|
5
5
|
"description": "MCP server for Brightspace (D2L). Check grades, due dates, assignments, announcements, syllabus, rosters and more via Claude, ChatGPT, Cursor, Windsurf, or any MCP client.",
|
|
6
6
|
"type": "module",
|