brightspace-mcp-server 3.13.4 → 3.13.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/auth/auth-runner.js +156 -9
- package/build/auth/mfa-challenge.js +9 -0
- package/build/auth-cli.js +2 -1
- package/package.json +1 -1
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* Licensed under MIT — see LICENSE file for details.
|
|
5
5
|
*/
|
|
6
6
|
import { spawn, execFileSync } from "node:child_process";
|
|
7
|
+
import * as fs from "node:fs/promises";
|
|
7
8
|
import { fileURLToPath } from "node:url";
|
|
8
9
|
import * as path from "node:path";
|
|
9
10
|
import { log } from "../utils/logger.js";
|
|
@@ -11,7 +12,7 @@ import { AuthError } from "../utils/errors.js";
|
|
|
11
12
|
import { AUTH_COMMAND } from "../utils/commands.js";
|
|
12
13
|
import { devActivity } from "../utils/dev-activity.js";
|
|
13
14
|
import { parsePhaseMarker } from "./auth-phases.js";
|
|
14
|
-
import { AUTOMATIC_PENDING_MARKER, authLockPath, challengeRelayedAt } from "./mfa-challenge.js";
|
|
15
|
+
import { AUTOMATIC_PENDING_MARKER, RELAYED_CHALLENGE_MARKER, authLockPath, challengeRelayedAt, relayChallenge } from "./mfa-challenge.js";
|
|
15
16
|
/**
|
|
16
17
|
* Timeout for the auth process. It has to outlast the child's own MFA wait,
|
|
17
18
|
* which is five minutes: a person has to find their phone, unlock it, and read
|
|
@@ -53,6 +54,20 @@ const CALL_BUDGET_MS = 55000;
|
|
|
53
54
|
* from another one) means nobody is approving.
|
|
54
55
|
*/
|
|
55
56
|
const ABANDON_MS = MFA_POLL_MS;
|
|
57
|
+
/**
|
|
58
|
+
* How often a caller waiting on ANOTHER process's sign-in looks at the lock.
|
|
59
|
+
* Each look also touches the challenge file (relayChallenge), which tells that
|
|
60
|
+
* process's abandonment watcher someone still wants its sign-in. It has to
|
|
61
|
+
* stay well under ABANDON_MS.
|
|
62
|
+
*/
|
|
63
|
+
const RELAY_POLL_MS = 1000;
|
|
64
|
+
/**
|
|
65
|
+
* How long a relayed challenge stays "already told to a caller" for the rule
|
|
66
|
+
* that only a RETRY waits on another process's sign-in. The MFA window is five
|
|
67
|
+
* minutes, so a number reported longer ago than that belongs to a sign-in that
|
|
68
|
+
* is over; the same digits showing up again are a new challenge.
|
|
69
|
+
*/
|
|
70
|
+
const RELAY_TOLD_MS = 5 * 60 * 1000;
|
|
56
71
|
/**
|
|
57
72
|
* Calls arriving within this long of a batch's first call count as that batch
|
|
58
73
|
* (issue #212). A client running tool calls in parallel sends them within
|
|
@@ -95,11 +110,12 @@ function mfaPendingFailure(numberMatch) {
|
|
|
95
110
|
* on anyone's phone to approve, so a caller told "approve the request" would
|
|
96
111
|
* send the user looking for a prompt that will never arrive.
|
|
97
112
|
*/
|
|
98
|
-
const automaticPendingFailure = () => new AuthProcessError("automaticPending", "Sign-in is entering its own verification code in the background. Try again.");
|
|
113
|
+
const automaticPendingFailure = (relayed = false) => new AuthProcessError("automaticPending", "Sign-in is entering its own verification code in the background. Try again.", undefined, false, relayed);
|
|
99
114
|
export class AuthProcessError extends AuthError {
|
|
100
115
|
kind;
|
|
101
116
|
numberMatch;
|
|
102
117
|
duplicate;
|
|
118
|
+
relayed;
|
|
103
119
|
constructor(kind, message,
|
|
104
120
|
/**
|
|
105
121
|
* Entra number-match digits, when the failure is "mfaPending". Crossed
|
|
@@ -113,11 +129,19 @@ export class AuthProcessError extends AuthError {
|
|
|
113
129
|
* challenge to the user (issue #212), so this one should answer briefly
|
|
114
130
|
* instead of repeating the number and the instructions.
|
|
115
131
|
*/
|
|
116
|
-
duplicate = false
|
|
132
|
+
duplicate = false,
|
|
133
|
+
/**
|
|
134
|
+
* The challenge belongs to a sign-in in ANOTHER server process that holds
|
|
135
|
+
* the cross-process lock; this process's child only relayed it and
|
|
136
|
+
* exited. There is no local child to join, so a caller that wants to
|
|
137
|
+
* keep waiting watches the lock instead (see AuthRunner.attendRelay).
|
|
138
|
+
*/
|
|
139
|
+
relayed = false) {
|
|
117
140
|
super(message);
|
|
118
141
|
this.kind = kind;
|
|
119
142
|
this.numberMatch = numberMatch;
|
|
120
143
|
this.duplicate = duplicate;
|
|
144
|
+
this.relayed = relayed;
|
|
121
145
|
this.name = "AuthProcessError";
|
|
122
146
|
}
|
|
123
147
|
}
|
|
@@ -197,8 +221,10 @@ function dedupeChallenge(error, wave) {
|
|
|
197
221
|
wave.closed = true;
|
|
198
222
|
return error;
|
|
199
223
|
}
|
|
200
|
-
return new AuthProcessError(error.kind, error.message, error.numberMatch, true);
|
|
224
|
+
return new AuthProcessError(error.kind, error.message, error.numberMatch, true, error.relayed);
|
|
201
225
|
}
|
|
226
|
+
/** Identifies a challenge for "was this already reported to a caller". */
|
|
227
|
+
const relayKey = (kind, numberMatch) => `${kind}:${numberMatch ?? ""}`;
|
|
202
228
|
export class AuthRunner {
|
|
203
229
|
/**
|
|
204
230
|
* The login this process already started, if one is still running.
|
|
@@ -237,6 +263,12 @@ export class AuthRunner {
|
|
|
237
263
|
* childDone; this only stops every call in the batch repeating the number.
|
|
238
264
|
*/
|
|
239
265
|
wave = null;
|
|
266
|
+
/**
|
|
267
|
+
* Challenges another process's sign-in is showing that a caller here was
|
|
268
|
+
* already told, by relayKey, and when. A retry of one of these waits on
|
|
269
|
+
* that sign-in instead of repeating the answer (see attendRelay).
|
|
270
|
+
*/
|
|
271
|
+
relayTold = new Map();
|
|
240
272
|
scriptPath;
|
|
241
273
|
timeoutMs;
|
|
242
274
|
onProgress;
|
|
@@ -268,11 +300,16 @@ export class AuthRunner {
|
|
|
268
300
|
async run(onChallenge) {
|
|
269
301
|
// Synchronous, before any await: calls in one tick cannot split a batch.
|
|
270
302
|
const wave = this.joinWave();
|
|
303
|
+
// Also synchronous: what was already told BEFORE this call, so siblings in
|
|
304
|
+
// one parallel batch are not each other's "retry" (they answer briefly via
|
|
305
|
+
// dedupeChallenge instead of waiting).
|
|
306
|
+
const toldBefore = this.relayChallengesTold();
|
|
271
307
|
this.waiting += 1;
|
|
272
308
|
try {
|
|
273
|
-
return await this.attend(onChallenge);
|
|
309
|
+
return await this.attend(onChallenge, toldBefore);
|
|
274
310
|
}
|
|
275
311
|
catch (error) {
|
|
312
|
+
this.noteRelayReported(error);
|
|
276
313
|
throw dedupeChallenge(error, wave);
|
|
277
314
|
}
|
|
278
315
|
finally {
|
|
@@ -280,6 +317,25 @@ export class AuthRunner {
|
|
|
280
317
|
this.lastAttendedAt = Date.now();
|
|
281
318
|
}
|
|
282
319
|
}
|
|
320
|
+
relayChallengesTold() {
|
|
321
|
+
const now = Date.now();
|
|
322
|
+
const told = new Set();
|
|
323
|
+
for (const [key, at] of this.relayTold) {
|
|
324
|
+
if (now - at > RELAY_TOLD_MS)
|
|
325
|
+
this.relayTold.delete(key);
|
|
326
|
+
else
|
|
327
|
+
told.add(key);
|
|
328
|
+
}
|
|
329
|
+
return told;
|
|
330
|
+
}
|
|
331
|
+
/** A relayed challenge that went back to a caller counts as told. */
|
|
332
|
+
noteRelayReported(error) {
|
|
333
|
+
if (!(error instanceof AuthProcessError) || !error.relayed)
|
|
334
|
+
return;
|
|
335
|
+
if (error.kind !== "mfaPending" && error.kind !== "automaticPending")
|
|
336
|
+
return;
|
|
337
|
+
this.relayTold.set(relayKey(error.kind, error.numberMatch), Date.now());
|
|
338
|
+
}
|
|
283
339
|
joinWave() {
|
|
284
340
|
const now = Date.now();
|
|
285
341
|
if (!this.wave || this.wave.closed || now - this.wave.openedAt > WAVE_MS) {
|
|
@@ -287,12 +343,15 @@ export class AuthRunner {
|
|
|
287
343
|
}
|
|
288
344
|
return this.wave;
|
|
289
345
|
}
|
|
290
|
-
async attend(onChallenge) {
|
|
346
|
+
async attend(onChallenge, toldBefore) {
|
|
291
347
|
const startedAt = Date.now();
|
|
292
348
|
try {
|
|
293
349
|
return await withinCallBudget(this.runOnce(startedAt), startedAt);
|
|
294
350
|
}
|
|
295
351
|
catch (error) {
|
|
352
|
+
if (this.sessionDir && error instanceof AuthProcessError && error.relayed) {
|
|
353
|
+
return this.attendRelay(error, startedAt, onChallenge, toldBefore);
|
|
354
|
+
}
|
|
296
355
|
const childDone = this.childDone;
|
|
297
356
|
if (!childDone || !(error instanceof AuthProcessError))
|
|
298
357
|
throw error;
|
|
@@ -314,6 +373,86 @@ export class AuthRunner {
|
|
|
314
373
|
return this.awaitBackgroundChild(childDone, windowMs);
|
|
315
374
|
}
|
|
316
375
|
}
|
|
376
|
+
/**
|
|
377
|
+
* The caller's sign-in found ANOTHER process's sign-in holding the lock,
|
|
378
|
+
* and that process is showing a challenge. The local child has already
|
|
379
|
+
* exited, so unlike awaitBackgroundChild there is nothing here to join:
|
|
380
|
+
* the lock is the only thing to watch.
|
|
381
|
+
*
|
|
382
|
+
* The first answer is still immediate for a caller that cannot be told
|
|
383
|
+
* mid-call, because the number is what the user needs and every second
|
|
384
|
+
* before it is a second the other sign-in's window runs down. A RETRY of the
|
|
385
|
+
* same challenge (already reported to a caller in this process) waits, like
|
|
386
|
+
* a retry joining a local child, instead of re-answering at once: answering
|
|
387
|
+
* a loop of retries in milliseconds made a model conclude the approval
|
|
388
|
+
* window had closed while the other sign-in was still waiting. A caller
|
|
389
|
+
* that can be told mid-call is told and waits at once, and an owner
|
|
390
|
+
* answering its own code has nothing to relay, so every caller waits.
|
|
391
|
+
*/
|
|
392
|
+
attendRelay(error, startedAt, onChallenge, toldBefore) {
|
|
393
|
+
const retry = toldBefore.has(relayKey(error.kind, error.numberMatch));
|
|
394
|
+
const waits = error.kind === "automaticPending"
|
|
395
|
+
|| (error.kind === "mfaPending" && (Boolean(onChallenge) || retry));
|
|
396
|
+
if (!waits)
|
|
397
|
+
throw error;
|
|
398
|
+
const windowMs = pollWindowMs(Date.now() - startedAt);
|
|
399
|
+
if (windowMs <= 0)
|
|
400
|
+
throw error;
|
|
401
|
+
if (error.kind === "mfaPending" && onChallenge) {
|
|
402
|
+
try {
|
|
403
|
+
onChallenge(error.numberMatch);
|
|
404
|
+
}
|
|
405
|
+
catch { /* Announcing must not interrupt authentication. */ }
|
|
406
|
+
this.noteRelayReported(error);
|
|
407
|
+
}
|
|
408
|
+
return this.awaitRelayedOwner(error, windowMs);
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* Wait up to windowMs for another process's sign-in to finish, by watching
|
|
412
|
+
* its lock directory about once a second.
|
|
413
|
+
*
|
|
414
|
+
* Each look also relays the challenge, which touches its file: that is the
|
|
415
|
+
* signal the owner's abandonment watcher (ABANDON_MS) reads as "someone is
|
|
416
|
+
* still waiting", so the owner is not killed under a user who is approving.
|
|
417
|
+
* The lock disappearing means the owner is done, and the caller resolves
|
|
418
|
+
* true so the client re-reads the session. If the owner FAILED there is no
|
|
419
|
+
* session, and the client's own "session expired" 401 follows; the next call
|
|
420
|
+
* starts a fresh sign-in and gets the owner's cooldown or error as usual.
|
|
421
|
+
* Spawning another sign-in here instead would risk a second MFA prompt for
|
|
422
|
+
* a login that just succeeded. When the window ends with the lock still
|
|
423
|
+
* held, the caller gets the latest challenge the owner showed.
|
|
424
|
+
*/
|
|
425
|
+
async awaitRelayedOwner(error, windowMs) {
|
|
426
|
+
const lockPath = authLockPath(this.sessionDir);
|
|
427
|
+
const deadline = Date.now() + windowMs;
|
|
428
|
+
let latest = {
|
|
429
|
+
automatic: error.kind === "automaticPending",
|
|
430
|
+
numberMatch: error.numberMatch,
|
|
431
|
+
};
|
|
432
|
+
for (;;) {
|
|
433
|
+
const held = await fs.access(lockPath).then(() => true, () => false);
|
|
434
|
+
if (!held) {
|
|
435
|
+
this.relayTold.clear();
|
|
436
|
+
return true;
|
|
437
|
+
}
|
|
438
|
+
const relayed = await relayChallenge(lockPath);
|
|
439
|
+
if (relayed) {
|
|
440
|
+
latest = relayed.kind === "automatic"
|
|
441
|
+
? { automatic: true }
|
|
442
|
+
: { automatic: false, numberMatch: relayed.numberMatch ?? (latest.automatic ? undefined : latest.numberMatch) };
|
|
443
|
+
}
|
|
444
|
+
const remaining = deadline - Date.now();
|
|
445
|
+
if (remaining <= 0)
|
|
446
|
+
break;
|
|
447
|
+
await new Promise((resolve) => {
|
|
448
|
+
const timer = setTimeout(resolve, Math.min(RELAY_POLL_MS, remaining));
|
|
449
|
+
timer.unref?.();
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
if (latest.automatic)
|
|
453
|
+
throw automaticPendingFailure(true);
|
|
454
|
+
throw new AuthProcessError(...mfaPendingFailure(latest.numberMatch), latest.numberMatch, false, true);
|
|
455
|
+
}
|
|
317
456
|
runOnce(startedAt) {
|
|
318
457
|
if (this.inFlight) {
|
|
319
458
|
log("DEBUG", "Joining the authentication already in flight");
|
|
@@ -429,6 +568,8 @@ export class AuthRunner {
|
|
|
429
568
|
let childFinished = false;
|
|
430
569
|
let numberMatch;
|
|
431
570
|
let challengeSeen = false;
|
|
571
|
+
/** The child said the challenge it is about to print is another process's. */
|
|
572
|
+
let relayed = false;
|
|
432
573
|
/** The child reported it is answering its own code (see the marker below). */
|
|
433
574
|
let automaticSeen = false;
|
|
434
575
|
/** watchAttention is armed once per child; a second timer could kill a live sign-in. */
|
|
@@ -542,7 +683,7 @@ export class AuthRunner {
|
|
|
542
683
|
if (firstChallenge)
|
|
543
684
|
resolveChallengeSignal();
|
|
544
685
|
if (!callerSettled) {
|
|
545
|
-
settleCaller(new AuthProcessError(...mfaPendingFailure(this.pendingChallenge?.numberMatch), this.pendingChallenge?.numberMatch));
|
|
686
|
+
settleCaller(new AuthProcessError(...mfaPendingFailure(this.pendingChallenge?.numberMatch), this.pendingChallenge?.numberMatch, false, relayed));
|
|
546
687
|
}
|
|
547
688
|
};
|
|
548
689
|
/**
|
|
@@ -558,7 +699,7 @@ export class AuthRunner {
|
|
|
558
699
|
this.pendingChallenge = { kind: "automaticPending" };
|
|
559
700
|
startWatching();
|
|
560
701
|
resolveChallengeSignal();
|
|
561
|
-
settleCaller(automaticPendingFailure());
|
|
702
|
+
settleCaller(automaticPendingFailure(relayed));
|
|
562
703
|
};
|
|
563
704
|
// Runs once the child is actually done. Settles the caller too, if an
|
|
564
705
|
// early answer had not already done so; otherwise this is just the
|
|
@@ -621,6 +762,11 @@ export class AuthRunner {
|
|
|
621
762
|
else if (line === AUTOMATIC_PENDING_MARKER) {
|
|
622
763
|
publishAutomaticProgress();
|
|
623
764
|
}
|
|
765
|
+
else if (line === RELAYED_CHALLENGE_MARKER) {
|
|
766
|
+
// Recorded as the line is read, so it is set before the challenge
|
|
767
|
+
// marker that follows it settles the caller.
|
|
768
|
+
relayed = true;
|
|
769
|
+
}
|
|
624
770
|
else {
|
|
625
771
|
log("DEBUG", line);
|
|
626
772
|
}
|
|
@@ -662,7 +808,8 @@ export class AuthRunner {
|
|
|
662
808
|
failures[2] = ["automaticPending", automaticPendingFailure().message];
|
|
663
809
|
const [kind, message] = failures[code ?? -1] ?? ["failed", `Authentication failed. Run ${AUTH_COMMAND} to try again.`];
|
|
664
810
|
kill("SIGKILL");
|
|
665
|
-
|
|
811
|
+
const challenged = kind === "mfaPending" || kind === "automaticPending";
|
|
812
|
+
finishChild(new AuthProcessError(kind, message, kind === "mfaPending" ? numberMatch : undefined, false, relayed && challenged));
|
|
666
813
|
}
|
|
667
814
|
});
|
|
668
815
|
});
|
|
@@ -77,6 +77,15 @@ export function challengeRelayedAt(lockPath) {
|
|
|
77
77
|
* beside challengeMarker so the child and AuthRunner cannot drift apart.
|
|
78
78
|
*/
|
|
79
79
|
export const AUTOMATIC_PENDING_MARKER = "AUTH_AUTOMATIC_PENDING";
|
|
80
|
+
/**
|
|
81
|
+
* Printed on the line before a challenge marker when the challenge is another
|
|
82
|
+
* process's, relayed from the lock directory, rather than this child's own.
|
|
83
|
+
* The parent then knows its caller is waiting on a sign-in it does not own,
|
|
84
|
+
* which it can only watch through the lock (see AuthRunner.awaitRelayedOwner).
|
|
85
|
+
* The child is always the same build as the parent, so no negotiation is
|
|
86
|
+
* needed.
|
|
87
|
+
*/
|
|
88
|
+
export const RELAYED_CHALLENGE_MARKER = "AUTH_RELAYED";
|
|
80
89
|
/** The stdout marker AuthRunner parses for a challenge. */
|
|
81
90
|
export function challengeMarker(numberMatch) {
|
|
82
91
|
return numberMatch ? `MFA_NUMBER:${numberMatch}` : "MFA_PENDING";
|
package/build/auth-cli.js
CHANGED
|
@@ -12,7 +12,7 @@ import { NativeCredentialStoreError } from "./auth/credential-store.js";
|
|
|
12
12
|
import { retireLegacyProfile } from "./auth/legacy-profile.js";
|
|
13
13
|
import { formatPhaseMarker } from "./auth/auth-phases.js";
|
|
14
14
|
import { AuthenticationInProgressError } from "./auth/auth-lock.js";
|
|
15
|
-
import { AUTOMATIC_PENDING_MARKER, challengeMarker } from "./auth/mfa-challenge.js";
|
|
15
|
+
import { AUTOMATIC_PENDING_MARKER, RELAYED_CHALLENGE_MARKER, challengeMarker } from "./auth/mfa-challenge.js";
|
|
16
16
|
import { runLogout } from "./auth/logout.js";
|
|
17
17
|
import { configureDevActivity } from "./utils/dev-activity.js";
|
|
18
18
|
import { AUTH_COMMAND, SETUP_COMMAND } from "./utils/commands.js";
|
|
@@ -112,6 +112,7 @@ async function main() {
|
|
|
112
112
|
// Another process's sign-in holds the lock and is showing a challenge:
|
|
113
113
|
// pass it on, so this caller can tell its user what to approve.
|
|
114
114
|
if (automatic && error instanceof AuthenticationInProgressError && error.challenge) {
|
|
115
|
+
console.log(RELAYED_CHALLENGE_MARKER);
|
|
115
116
|
console.log(error.challenge.kind === "automatic"
|
|
116
117
|
? AUTOMATIC_PENDING_MARKER
|
|
117
118
|
: challengeMarker(error.challenge.numberMatch));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brightspace-mcp-server",
|
|
3
|
-
"version": "3.13.
|
|
3
|
+
"version": "3.13.7",
|
|
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",
|