brightspace-mcp-server 5.1.19 → 5.1.21
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 +1 -1
- package/build/api/client.js +4 -2
- package/build/api/errors.js +13 -0
- package/build/api/index.js +1 -1
- package/build/auth/auth-lock.js +11 -0
- package/build/auth/auth-runner.js +156 -8
- package/build/auth/browser-auth.js +6 -1
- package/build/auth/mfa-challenge.js +5 -0
- package/build/auth/purdue-sso.js +91 -0
- package/build/auth/sso-flow.js +2 -1
- package/build/auth-cli.js +5 -2
- package/build/tools/get-announcements.js +4 -1
- package/build/tools/get-assignments.js +3 -1
- package/build/tools/get-calendar-events.js +2 -1
- package/build/tools/get-my-grades.js +3 -1
- package/build/tools/get-roster.js +4 -1
- package/build/tools/get-upcoming-due-dates.js +2 -1
- package/build/tools/sign-out.js +28 -12
- package/build/tools/tool-helpers.js +56 -8
- package/build/utils/dev-activity.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -159,7 +159,7 @@ npx -y brightspace-mcp-server@latest auth
|
|
|
159
159
|
|
|
160
160
|
**Want fewer 2FA prompts?** `setup` asks one question, "How often do you want to approve sign-ins on your phone?": **1** every time (default), **2** remember this computer, or **3** let this computer answer Microsoft's code requests itself (needs an authenticator setup key). Options 2 and 3 are opt-in and not for shared computers; to turn them off, rerun `setup` and pick 1. The tradeoffs and the steps for getting the key are in [docs/sign-in.md](docs/sign-in.md#fewer-2fa-prompts).
|
|
161
161
|
|
|
162
|
-
**To sign out early** (on a shared computer, say), just ask your AI app to sign you out of Brightspace, or run `npx -y brightspace-mcp-server@latest auth --logout` in a terminal. It deletes this computer's saved Brightspace session, so the next question does a full sign-in. Your saved password, authenticator key, and settings are kept, so with a saved authenticator key that sign-in can finish without you; on a shared computer, also delete the saved `brightspace-mcp-server` entries from your computer's password store (Keychain Access on a Mac, Credential Manager on Windows). Other AI apps open on this computer are signed out too and will ask you to sign in on their next question. (An app you opened before updating may keep answering for up to an hour; quit and reopen it to sign it out right away.) It only clears this computer; it does not sign you out of Microsoft or Brightspace anywhere else.
|
|
162
|
+
**To sign out early** (on a shared computer, say), just ask your AI app to sign you out of Brightspace, or run `npx -y brightspace-mcp-server@latest auth --logout` in a terminal. It deletes this computer's saved Brightspace session, so the next question does a full sign-in. Your saved password, authenticator key, and settings are kept, so with a saved authenticator key that sign-in can finish without you; on a shared computer, also delete the saved `brightspace-mcp-server` entries from your computer's password store (Keychain Access on a Mac, Credential Manager on Windows). Other AI apps open on this computer are signed out too and will ask you to sign in on their next question. (An app you opened before updating may keep answering for up to an hour; quit and reopen it to sign it out right away.) It only clears this computer; it does not sign you out of Microsoft or Brightspace anywhere else. Asking your AI app also cancels a sign-in still waiting for you to approve it; the `auth --logout` command instead refuses and deletes nothing while a sign-in is in progress. Cancelling doesn't get you a new prompt any sooner: Microsoft won't send another until the earlier one expires, up to 5 minutes after it was sent.
|
|
163
163
|
|
|
164
164
|
How often you're asked is up to your school, not this tool. Everything else about sign-in — Duo, authenticator codes, visible-browser mode, and running without a browser at all (Docker, WSL, hardware keys) — is in [docs/sign-in.md](docs/sign-in.md).
|
|
165
165
|
|
package/build/api/client.js
CHANGED
|
@@ -7,7 +7,7 @@ import { DEFAULT_CACHE_TTLS } from "./types.js";
|
|
|
7
7
|
import { TTLCache } from "./cache.js";
|
|
8
8
|
import { TokenBucket } from "./rate-limiter.js";
|
|
9
9
|
import { discoverVersions } from "./version-discovery.js";
|
|
10
|
-
import { ApiError, RateLimitError, NetworkError } from "./errors.js";
|
|
10
|
+
import { ApiError, CredentialRejectedError, RateLimitError, NetworkError } from "./errors.js";
|
|
11
11
|
import { withRetry, isRetryableFailure, retryAfterMsFrom, parseRetryAfter, } from "./retry.js";
|
|
12
12
|
import { log } from "../utils/logger.js";
|
|
13
13
|
import { AUTH_COMMAND } from "../utils/commands.js";
|
|
@@ -475,7 +475,9 @@ export class D2LApiClient {
|
|
|
475
475
|
}
|
|
476
476
|
log("WARN", "Auto-reauthentication did not produce a valid token");
|
|
477
477
|
}
|
|
478
|
-
|
|
478
|
+
if (this.authExpiredMessage)
|
|
479
|
+
throw new CredentialRejectedError(path, this.authExpiredMessage);
|
|
480
|
+
throw new ApiError(401, path, `Session expired. Please re-authenticate via ${AUTH_COMMAND}.`);
|
|
479
481
|
}
|
|
480
482
|
/**
|
|
481
483
|
* Make one HTTP request. Authentication recovery is shared with raw downloads.
|
package/build/api/errors.js
CHANGED
|
@@ -17,6 +17,19 @@ export class ApiError extends AuthError {
|
|
|
17
17
|
this.name = "ApiError";
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
|
+
/**
|
|
21
|
+
* A 401 for a credential pasted into the environment (D2L_ACCESS_TOKEN or
|
|
22
|
+
* D2L_SESSION_COOKIE). No browser sign-in stands behind it, so the user is
|
|
23
|
+
* told how to replace the credential rather than to run the auth command.
|
|
24
|
+
*/
|
|
25
|
+
export class CredentialRejectedError extends ApiError {
|
|
26
|
+
guidance;
|
|
27
|
+
constructor(endpoint, guidance) {
|
|
28
|
+
super(401, endpoint, guidance);
|
|
29
|
+
this.guidance = guidance;
|
|
30
|
+
this.name = "CredentialRejectedError";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
20
33
|
// Generic HTTP error (non-401/429) - semantic alias for ApiError
|
|
21
34
|
export class HttpError extends ApiError {
|
|
22
35
|
constructor(status, endpoint, message, responseBody, cause) {
|
package/build/api/index.js
CHANGED
|
@@ -12,5 +12,5 @@ export { discoverVersions } from "./version-discovery.js";
|
|
|
12
12
|
export { TTLCache } from "./cache.js";
|
|
13
13
|
export { TokenBucket } from "./rate-limiter.js";
|
|
14
14
|
// Errors
|
|
15
|
-
export { ApiError, RateLimitError, NetworkError } from "./errors.js";
|
|
15
|
+
export { ApiError, CredentialRejectedError, RateLimitError, NetworkError } from "./errors.js";
|
|
16
16
|
export { DEFAULT_CACHE_TTLS } from "./types.js";
|
package/build/auth/auth-lock.js
CHANGED
|
@@ -116,6 +116,17 @@ export async function inspectLockOwner(lockPath) {
|
|
|
116
116
|
return undefined;
|
|
117
117
|
return { mode: owner.mode === "automatic" ? "automatic" : "explicit", gone: lockOps.isDead(owner) };
|
|
118
118
|
}
|
|
119
|
+
/**
|
|
120
|
+
* The PID of a live "automatic" owner (a background AuthRunner child), or
|
|
121
|
+
* undefined when the lock is free, its owner is gone, or it is held some other
|
|
122
|
+
* way. Used by sign_out to cancel another app's waiting sign-in.
|
|
123
|
+
*/
|
|
124
|
+
export async function liveAutomaticOwner(lockPath) {
|
|
125
|
+
const owner = await readOwner(lockPath);
|
|
126
|
+
if (!owner || owner.mode !== "automatic" || lockOps.isDead(owner))
|
|
127
|
+
return undefined;
|
|
128
|
+
return owner.pid;
|
|
129
|
+
}
|
|
119
130
|
/** Remove only our fixed metadata names, after the directory was retired. */
|
|
120
131
|
async function cleanRetired(lockPath) {
|
|
121
132
|
await fs.unlink(path.join(lockPath, "owner.json")).catch(() => { });
|
|
@@ -13,8 +13,8 @@ import { AUTH_COMMAND } from "../utils/commands.js";
|
|
|
13
13
|
import { dependencyRepairHint, PLAYWRIGHT_INSTALL_HINT } from "../utils/browser-install.js";
|
|
14
14
|
import { devActivity, newRecoveryId, RECOVERY_ID_ENV } from "../utils/dev-activity.js";
|
|
15
15
|
import { parsePhaseMarker } from "./auth-phases.js";
|
|
16
|
-
import { AUTOMATIC_PENDING_MARKER, RELAYED_CHALLENGE_MARKER, RELAYED_STARTING_MARKER, authLockPath, challengeRelayedAt, relayChallenge } from "./mfa-challenge.js";
|
|
17
|
-
import { inspectLockOwner } from "./auth-lock.js";
|
|
16
|
+
import { AUTOMATIC_PENDING_MARKER, MFA_HELD_MARKER, RELAYED_CHALLENGE_MARKER, RELAYED_STARTING_MARKER, authLockPath, challengeRelayedAt, relayChallenge } from "./mfa-challenge.js";
|
|
17
|
+
import { inspectLockOwner, liveAutomaticOwner } from "./auth-lock.js";
|
|
18
18
|
/**
|
|
19
19
|
* Timeout for the auth process. It has to outlast the child's own MFA wait,
|
|
20
20
|
* which is five minutes: a person has to find their phone, unlock it, and read
|
|
@@ -134,6 +134,14 @@ function mfaPendingFailure(numberMatch) {
|
|
|
134
134
|
* send the user looking for a prompt that will never arrive.
|
|
135
135
|
*/
|
|
136
136
|
const automaticPendingFailure = (relayed = false) => new AuthProcessError("automaticPending", "Sign-in is entering its own verification code in the background. Try again.", undefined, false, relayed);
|
|
137
|
+
/**
|
|
138
|
+
* Microsoft is refusing a new prompt for the account until an earlier one
|
|
139
|
+
* expires. Nothing is on the user's phone to approve, and calling again does
|
|
140
|
+
* not help sooner, so it is neither "approve now" nor "still starting".
|
|
141
|
+
*/
|
|
142
|
+
const mfaHeldFailure = () => new AuthProcessError("mfaHeld", "Microsoft is holding a new prompt until an earlier one expires. Try again in a minute.");
|
|
143
|
+
/** What every caller of a sign-in that was stopped from outside (sign_out, a kill) is answered. */
|
|
144
|
+
const cancelledFailure = (relayed = false) => new AuthProcessError("cancelled", "The sign-in was cancelled before it finished.", undefined, false, relayed);
|
|
137
145
|
export class AuthProcessError extends AuthError {
|
|
138
146
|
kind;
|
|
139
147
|
numberMatch;
|
|
@@ -200,6 +208,80 @@ function descendantPids(parentPid) {
|
|
|
200
208
|
visit(parentPid);
|
|
201
209
|
return descendants;
|
|
202
210
|
}
|
|
211
|
+
/**
|
|
212
|
+
* SIGKILL a sign-in child, its Chromium descendants and its process group.
|
|
213
|
+
* SIGKILL rather than SIGTERM: a graceful stop runs the MFA loop's failure
|
|
214
|
+
* path, which records the cooldown, and the user never had a chance to answer.
|
|
215
|
+
*/
|
|
216
|
+
function forceStop(pid) {
|
|
217
|
+
if (process.platform === "win32") {
|
|
218
|
+
try {
|
|
219
|
+
execFileSync("taskkill", ["/pid", String(pid), "/t", "/f"], { stdio: "ignore", timeout: 5000 });
|
|
220
|
+
}
|
|
221
|
+
catch { /* Already exited. */ }
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
try {
|
|
225
|
+
for (const descendant of descendantPids(pid)) {
|
|
226
|
+
try {
|
|
227
|
+
process.kill(descendant, "SIGKILL");
|
|
228
|
+
}
|
|
229
|
+
catch { /* Already exited. */ }
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
catch { /* Still terminate the owned process group if ps is unavailable. */ }
|
|
233
|
+
try {
|
|
234
|
+
process.kill(-pid, "SIGKILL");
|
|
235
|
+
}
|
|
236
|
+
catch { /* Not a group leader, or gone. */ }
|
|
237
|
+
try {
|
|
238
|
+
process.kill(pid, "SIGKILL");
|
|
239
|
+
}
|
|
240
|
+
catch { /* Already exited. */ }
|
|
241
|
+
}
|
|
242
|
+
/** How long a cancel waits for a stopped sign-in to actually exit. */
|
|
243
|
+
const CANCEL_WAIT_MS = 5000;
|
|
244
|
+
/**
|
|
245
|
+
* Stop a background sign-in that ANOTHER server process on this computer
|
|
246
|
+
* started and that holds the cross-process lock, so sign_out can cancel a
|
|
247
|
+
* sign-in whichever app began it. Only an "automatic" owner is stopped (a
|
|
248
|
+
* person's `auth` run in a terminal never is), and only after its command
|
|
249
|
+
* line confirms it is this package's sign-in child: the PID comes from a file,
|
|
250
|
+
* and a PID the system has since reused must never be killed. That check
|
|
251
|
+
* needs ps, so on Windows nothing is stopped and sign_out reports the sign-in
|
|
252
|
+
* as one it cannot cancel. Its own server's waiting calls are answered
|
|
253
|
+
* "cancelled" (see the close handler in spawnAuth). The dead owner leaves a
|
|
254
|
+
* stale lock the next lock holder reclaims. Resolves true once it has exited.
|
|
255
|
+
*/
|
|
256
|
+
export async function stopAutomaticSignIn(lockPath) {
|
|
257
|
+
if (process.platform === "win32")
|
|
258
|
+
return false;
|
|
259
|
+
const pid = await liveAutomaticOwner(lockPath);
|
|
260
|
+
if (pid === undefined)
|
|
261
|
+
return false;
|
|
262
|
+
let command = "";
|
|
263
|
+
try {
|
|
264
|
+
command = execFileSync("ps", ["-o", "command=", "-p", String(pid)], { encoding: "utf8", timeout: 1000 });
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
return false;
|
|
268
|
+
}
|
|
269
|
+
if (!/auth-cli\.js\b/.test(command) || !command.includes("--automatic"))
|
|
270
|
+
return false;
|
|
271
|
+
log("WARN", `Cancelling another app's background sign-in (pid ${pid}) for sign_out`);
|
|
272
|
+
forceStop(pid);
|
|
273
|
+
const deadline = Date.now() + CANCEL_WAIT_MS;
|
|
274
|
+
while (Date.now() < deadline) {
|
|
275
|
+
try {
|
|
276
|
+
process.kill(pid, 0);
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
return true;
|
|
280
|
+
}
|
|
281
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
282
|
+
}
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
203
285
|
/**
|
|
204
286
|
* Forward a child stream to the server log, one line at a time.
|
|
205
287
|
*
|
|
@@ -322,6 +404,8 @@ export class AuthRunner {
|
|
|
322
404
|
* which can linger for seconds after it has said everything it will say.
|
|
323
405
|
*/
|
|
324
406
|
relayProbe = null;
|
|
407
|
+
/** Stops the current child and resolves once it has exited; null between children. */
|
|
408
|
+
cancelChild = null;
|
|
325
409
|
scriptPath;
|
|
326
410
|
timeoutMs;
|
|
327
411
|
onProgress;
|
|
@@ -344,6 +428,22 @@ export class AuthRunner {
|
|
|
344
428
|
busy() {
|
|
345
429
|
return this.inFlight !== null || this.childDone !== null;
|
|
346
430
|
}
|
|
431
|
+
/**
|
|
432
|
+
* Cancel this process's sign-in, if one is running: stop the child at once
|
|
433
|
+
* (no MFA cooldown is recorded, see forceStop) and answer every call waiting
|
|
434
|
+
* on it, here or behind the shared HTTP server, with a "cancelled" error
|
|
435
|
+
* instead of leaving it to time out. The next call starts a new sign-in and
|
|
436
|
+
* sends a new prompt. Resolves true once the child has exited, so the
|
|
437
|
+
* cross-process lock it held is reclaimable; false when nothing was running.
|
|
438
|
+
*/
|
|
439
|
+
async cancel() {
|
|
440
|
+
const cancelChild = this.cancelChild;
|
|
441
|
+
if (!cancelChild)
|
|
442
|
+
return false;
|
|
443
|
+
log("INFO", "Cancelling the background sign-in for sign_out");
|
|
444
|
+
await cancelChild();
|
|
445
|
+
return true;
|
|
446
|
+
}
|
|
347
447
|
/**
|
|
348
448
|
* Authenticate, joining the login this process already started if there is
|
|
349
449
|
* one. Returns true on success and throws a useful error on failure.
|
|
@@ -547,10 +647,11 @@ export class AuthRunner {
|
|
|
547
647
|
this.relayTold.clear();
|
|
548
648
|
return true;
|
|
549
649
|
}
|
|
550
|
-
// The owner died without releasing its lock
|
|
551
|
-
// the clock down; the next call's
|
|
650
|
+
// The owner died without releasing its lock: cancelled (sign_out) or
|
|
651
|
+
// crashed. Waiting longer only runs the clock down; the next call's
|
|
652
|
+
// sign-in reclaims the dead lock.
|
|
552
653
|
if ((await inspectLockOwner(lockPath))?.gone)
|
|
553
|
-
throw
|
|
654
|
+
throw cancelledFailure(true);
|
|
554
655
|
const relayed = await relayChallenge(lockPath);
|
|
555
656
|
if (relayed) {
|
|
556
657
|
const next = relayed.kind === "automatic"
|
|
@@ -664,6 +765,10 @@ export class AuthRunner {
|
|
|
664
765
|
reject(automaticPendingFailure());
|
|
665
766
|
return;
|
|
666
767
|
}
|
|
768
|
+
if (pending?.kind === "mfaHeld") {
|
|
769
|
+
reject(mfaHeldFailure());
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
667
772
|
const numberMatch = this.pendingChallenge?.numberMatch ?? challenge?.numberMatch;
|
|
668
773
|
reject(new AuthProcessError(...mfaPendingFailure(numberMatch), numberMatch));
|
|
669
774
|
}, graceMs);
|
|
@@ -725,6 +830,8 @@ export class AuthRunner {
|
|
|
725
830
|
let relayed = false;
|
|
726
831
|
/** The child reported it is answering its own code (see the marker below). */
|
|
727
832
|
let automaticSeen = false;
|
|
833
|
+
/** Microsoft held back a new prompt at some point in this sign-in (MFA_HELD_MARKER). */
|
|
834
|
+
let heldSeen = false;
|
|
728
835
|
/** watchAttention is armed once per child; a second timer could kill a live sign-in. */
|
|
729
836
|
let attentionWatched = false;
|
|
730
837
|
let abandonTimer;
|
|
@@ -759,6 +866,7 @@ export class AuthRunner {
|
|
|
759
866
|
};
|
|
760
867
|
const onExit = () => kill("SIGTERM");
|
|
761
868
|
process.once("exit", onExit);
|
|
869
|
+
const exited = new Promise((resolve) => child.once("exit", () => resolve()));
|
|
762
870
|
const settleCaller = (error) => {
|
|
763
871
|
if (callerSettled)
|
|
764
872
|
return;
|
|
@@ -788,10 +896,21 @@ export class AuthRunner {
|
|
|
788
896
|
this.challengeTold = null;
|
|
789
897
|
this.challengeToldKey = null;
|
|
790
898
|
this.relayProbe = null;
|
|
899
|
+
this.cancelChild = null;
|
|
791
900
|
}
|
|
792
901
|
});
|
|
793
902
|
trackedCompletion.catch(() => { });
|
|
794
903
|
this.childDone = trackedCompletion;
|
|
904
|
+
this.cancelChild = async () => {
|
|
905
|
+
if (!childFinished) {
|
|
906
|
+
if (child.pid)
|
|
907
|
+
forceStop(child.pid);
|
|
908
|
+
else
|
|
909
|
+
child.kill("SIGKILL");
|
|
910
|
+
finishChild(cancelledFailure());
|
|
911
|
+
}
|
|
912
|
+
await Promise.race([exited, new Promise((resolve) => setTimeout(resolve, CANCEL_WAIT_MS).unref?.())]);
|
|
913
|
+
};
|
|
795
914
|
this.pendingChallenge = null;
|
|
796
915
|
this.challengeToldKey = null;
|
|
797
916
|
this.relayProbe = null;
|
|
@@ -831,7 +950,9 @@ export class AuthRunner {
|
|
|
831
950
|
// An automatic sign-in that later shows a real approval challenge (it
|
|
832
951
|
// fell back, or Entra changed its mind) must be upgraded, so a joiner
|
|
833
952
|
// is told the number instead of "still working".
|
|
834
|
-
|
|
953
|
+
// A hold is replaced by any real challenge, numbered or not.
|
|
954
|
+
const firstChallenge = this.pendingChallenge === null || this.pendingChallenge.kind === "automaticPending" ||
|
|
955
|
+
this.pendingChallenge.kind === "mfaHeld";
|
|
835
956
|
startWatching();
|
|
836
957
|
if (firstChallenge)
|
|
837
958
|
devActivity("mfa_observed", { elapsedMs: Date.now() - started, recoveryId });
|
|
@@ -868,6 +989,20 @@ export class AuthRunner {
|
|
|
868
989
|
this.relayProbe = answer;
|
|
869
990
|
settleCaller(answer);
|
|
870
991
|
};
|
|
992
|
+
/**
|
|
993
|
+
* Microsoft refused a new prompt while an earlier one is open. Answer
|
|
994
|
+
* the waiting caller now with the real wait; a number that appears
|
|
995
|
+
* later replaces this (it is published like any challenge).
|
|
996
|
+
*/
|
|
997
|
+
const publishHeld = () => {
|
|
998
|
+
heldSeen = true;
|
|
999
|
+
if (this.pendingChallenge?.kind === "mfaPending")
|
|
1000
|
+
return;
|
|
1001
|
+
this.pendingChallenge = { kind: "mfaHeld" };
|
|
1002
|
+
startWatching();
|
|
1003
|
+
resolveChallengeSignal();
|
|
1004
|
+
settleCaller(mfaHeldFailure());
|
|
1005
|
+
};
|
|
871
1006
|
// Runs once the child is actually done. Settles the caller too, if an
|
|
872
1007
|
// early answer had not already done so; otherwise this is just the
|
|
873
1008
|
// background sign-in finishing, which only childDone's joiner sees.
|
|
@@ -929,6 +1064,9 @@ export class AuthRunner {
|
|
|
929
1064
|
else if (line === AUTOMATIC_PENDING_MARKER) {
|
|
930
1065
|
publishAutomaticProgress();
|
|
931
1066
|
}
|
|
1067
|
+
else if (line === MFA_HELD_MARKER) {
|
|
1068
|
+
publishHeld();
|
|
1069
|
+
}
|
|
932
1070
|
else if (line === RELAYED_CHALLENGE_MARKER) {
|
|
933
1071
|
// Recorded as the line is read, so it is set before the challenge
|
|
934
1072
|
// marker that follows it settles the caller.
|
|
@@ -957,10 +1095,16 @@ export class AuthRunner {
|
|
|
957
1095
|
kill("SIGKILL");
|
|
958
1096
|
finishChild(new AuthProcessError("failed", `Could not start authentication. Run ${AUTH_COMMAND} for details.`));
|
|
959
1097
|
});
|
|
960
|
-
child.on("close", (code) => {
|
|
1098
|
+
child.on("close", (code, signal) => {
|
|
961
1099
|
if (childFinished)
|
|
962
1100
|
return;
|
|
963
|
-
if (timedOut) {
|
|
1101
|
+
if (!timedOut && signal === "SIGKILL") {
|
|
1102
|
+
// Stopped from outside this process: another app's sign_out (see
|
|
1103
|
+
// stopAutomaticSignIn), or someone killed it. Either way it will not
|
|
1104
|
+
// finish, and the next call starts a new sign-in.
|
|
1105
|
+
finishChild(cancelledFailure());
|
|
1106
|
+
}
|
|
1107
|
+
else if (timedOut) {
|
|
964
1108
|
kill("SIGKILL");
|
|
965
1109
|
finishChild(new AuthProcessError("timeout", `Authentication timed out. Run ${AUTH_COMMAND} to try again.`));
|
|
966
1110
|
}
|
|
@@ -994,6 +1138,10 @@ export class AuthRunner {
|
|
|
994
1138
|
// challenge yet: the caller can wait on it (see attendRelay).
|
|
995
1139
|
else if (code === 2 && relayed && this.sessionDir)
|
|
996
1140
|
failures[2] = ["inProgress", STILL_SIGNING_IN];
|
|
1141
|
+
// Unsupported only because Microsoft held every new prompt until
|
|
1142
|
+
// this sign-in ran out of time: the next call starts a fresh one.
|
|
1143
|
+
if (code === 4 && heldSeen)
|
|
1144
|
+
failures[4] = ["mfaHeld", mfaHeldFailure().message];
|
|
997
1145
|
const [kind, message] = failures[code ?? -1] ?? ["failed", `Authentication failed. Run ${AUTH_COMMAND} to try again.`];
|
|
998
1146
|
kill("SIGKILL");
|
|
999
1147
|
const challenged = kind === "mfaPending" || kind === "automaticPending" || kind === "inProgress";
|
|
@@ -84,7 +84,12 @@ export class BrowserAuth {
|
|
|
84
84
|
onAutomaticPending?.();
|
|
85
85
|
}
|
|
86
86
|
catch { /* Reporting must not interrupt authentication. */ }
|
|
87
|
-
})
|
|
87
|
+
}, options.onMfaHeld && (() => {
|
|
88
|
+
try {
|
|
89
|
+
options.onMfaHeld?.();
|
|
90
|
+
}
|
|
91
|
+
catch { /* Reporting must not interrupt authentication. */ }
|
|
92
|
+
}));
|
|
88
93
|
this.stateStore = new BrowserStateStore(config.sessionDir);
|
|
89
94
|
this.cooldown = new AuthCooldown(config.sessionDir);
|
|
90
95
|
}
|
|
@@ -77,6 +77,11 @@ 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
|
+
* The child reports that Microsoft is refusing to send a new prompt because an
|
|
82
|
+
* earlier one for the account is still open (see PurdueSSOFlow's onMfaHeld).
|
|
83
|
+
*/
|
|
84
|
+
export const MFA_HELD_MARKER = "AUTH_MFA_HELD";
|
|
80
85
|
/**
|
|
81
86
|
* Printed on the line before a challenge marker when the challenge is another
|
|
82
87
|
* process's, relayed from the lock directory, rather than this child's own.
|
package/build/auth/purdue-sso.js
CHANGED
|
@@ -120,6 +120,31 @@ const CODE_METHOD_STEPS = [
|
|
|
120
120
|
["code", /^use a verification code$/i],
|
|
121
121
|
["other", /^(?:I can.t use my .+ right now|sign in another way|use a different verification option)$/i],
|
|
122
122
|
];
|
|
123
|
+
/**
|
|
124
|
+
* Entra's "Verify your identity" heading when it refuses a new prompt: an
|
|
125
|
+
* earlier prompt for the account is still open (BeginAuth answers
|
|
126
|
+
* UserAuthFailedDuplicateRequest, error 500121), whether it was missed or its
|
|
127
|
+
* sign-in was cancelled. It lasts until that prompt expires, up to 5 minutes
|
|
128
|
+
* after it was sent. Seen live on Purdue's tenant on 2026-10-25, where asking
|
|
129
|
+
* the list for another Authenticator request during the hold was refused the
|
|
130
|
+
* same way (see HELD_RETRY_MS for asking again once it may have ended).
|
|
131
|
+
*/
|
|
132
|
+
const MFA_HELD_TEXT = /having trouble verifying your account/i;
|
|
133
|
+
/**
|
|
134
|
+
* While Microsoft holds the account, ask it again through the list's
|
|
135
|
+
* Authenticator option this often. A request made during the hold is refused
|
|
136
|
+
* and sends nothing to the phone; the first one after the earlier prompt
|
|
137
|
+
* expires sends exactly one new prompt, whose number ends the hold here.
|
|
138
|
+
* Without this the page never changes and the login waits out its own 5
|
|
139
|
+
* minutes after Microsoft would already have sent a new prompt.
|
|
140
|
+
*/
|
|
141
|
+
const HELD_RETRY_MS = 60_000;
|
|
142
|
+
/** Enough tries to outlast a 5-minute hold, and no more. */
|
|
143
|
+
const MAX_HELD_RETRIES = 6;
|
|
144
|
+
const PUSH_METHOD_SELECTOR = '[data-value="PhoneAppNotification"]';
|
|
145
|
+
const PUSH_METHOD_LABEL = /^approve a request on my microsoft authenticator app$/i;
|
|
146
|
+
/** Short, so a list that changes under the click cannot hold the loop for Playwright's 30 s default. */
|
|
147
|
+
const PUSH_CLICK_TIMEOUT_MS = 5000;
|
|
123
148
|
/** Let Entra re-render the method page before the loop looks at it again. */
|
|
124
149
|
const METHOD_SWITCH_SETTLE_MS = 500;
|
|
125
150
|
/**
|
|
@@ -389,6 +414,11 @@ export class PurdueSSOFlow {
|
|
|
389
414
|
const startedAt = Date.now();
|
|
390
415
|
const deadline = startedAt + MFA_TIMEOUT_MS;
|
|
391
416
|
let challenged = false;
|
|
417
|
+
/** True once onMfaHeld has reported this login. */
|
|
418
|
+
let heldReported = false;
|
|
419
|
+
/** Requests made through the held list, and when the last one was made. */
|
|
420
|
+
let heldRetries = 0;
|
|
421
|
+
let heldRetryAt = 0;
|
|
392
422
|
/**
|
|
393
423
|
* True once this login has told the user to go approve something. Only
|
|
394
424
|
* then is a timeout really a missed approval; an automatic code sign-in
|
|
@@ -481,6 +511,24 @@ export class PurdueSSOFlow {
|
|
|
481
511
|
await page.locator("#idDiv_SAOTCAS_Title").first().isVisible().catch(() => false) ||
|
|
482
512
|
await page.locator("#idDiv_SAOTCC_Title").first().isVisible().catch(() => false) ||
|
|
483
513
|
await this.anyVisible(page, PASSWORDLESS_APPROVAL_SELECTORS);
|
|
514
|
+
// Microsoft refusing a new prompt while an earlier one is still open:
|
|
515
|
+
// tell the caller how long that lasts instead of "still starting".
|
|
516
|
+
if (manualRequired && !challengeVisible && await this.isHeldByMicrosoft(page)) {
|
|
517
|
+
if (!heldReported) {
|
|
518
|
+
heldReported = true;
|
|
519
|
+
heldRetryAt = Date.now();
|
|
520
|
+
log("WARN", "Microsoft is holding back a new prompt until an earlier one for this account expires (up to 5 minutes after it was sent).");
|
|
521
|
+
this.config.onMfaHeld?.();
|
|
522
|
+
}
|
|
523
|
+
else if (!sawNumber && heldRetries < MAX_HELD_RETRIES && Date.now() - heldRetryAt >= HELD_RETRY_MS) {
|
|
524
|
+
heldRetryAt = Date.now();
|
|
525
|
+
if (await this.requestPromptFromHeldList(page)) {
|
|
526
|
+
heldRetries += 1;
|
|
527
|
+
await page.waitForTimeout(METHOD_SWITCH_SETTLE_MS);
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
484
532
|
// Entra showed a challenge but never a way to type a code. Stop
|
|
485
533
|
// waiting for one and fall back to the announce-and-approve path this
|
|
486
534
|
// flow has always used, rather than polling a dead page for 5 minutes.
|
|
@@ -739,6 +787,49 @@ export class PurdueSSOFlow {
|
|
|
739
787
|
}
|
|
740
788
|
return "noSwitch";
|
|
741
789
|
}
|
|
790
|
+
/**
|
|
791
|
+
* Ask the held list for an Authenticator prompt (see HELD_RETRY_MS). Only
|
|
792
|
+
* when Microsoft's page names the configured account, so a held list for
|
|
793
|
+
* someone else never sends that person a prompt.
|
|
794
|
+
*/
|
|
795
|
+
async requestPromptFromHeldList(page) {
|
|
796
|
+
const tile = page.locator(PUSH_METHOD_SELECTOR).first();
|
|
797
|
+
const control = await tile.isVisible().catch(() => false) ? tile : page.getByText(PUSH_METHOD_LABEL).first();
|
|
798
|
+
if (!await control.isVisible().catch(() => false))
|
|
799
|
+
return false;
|
|
800
|
+
if (!await this.showsConfiguredAccount(page))
|
|
801
|
+
return false;
|
|
802
|
+
log("INFO", "Asking Microsoft again for an Authenticator prompt.");
|
|
803
|
+
await control.click({ timeout: PUSH_CLICK_TIMEOUT_MS }).catch(() => { });
|
|
804
|
+
devActivity("mfa_method_selected");
|
|
805
|
+
return true;
|
|
806
|
+
}
|
|
807
|
+
/**
|
|
808
|
+
* True only when this is Microsoft's own sign-in page and one of its account
|
|
809
|
+
* labels names the configured account. Stricter than
|
|
810
|
+
* assertExpectedMicrosoftAccount, which lets a page with no label through.
|
|
811
|
+
*/
|
|
812
|
+
async showsConfiguredAccount(page) {
|
|
813
|
+
if (new URL(page.url()).hostname !== "login.microsoftonline.com" || !this.config.username)
|
|
814
|
+
return false;
|
|
815
|
+
const expected = signInName(this.config.username, this.config.baseUrl).toLowerCase();
|
|
816
|
+
for (const selector of ACCOUNT_LABEL_SELECTORS) {
|
|
817
|
+
const account = page.locator(selector).first();
|
|
818
|
+
if (await account.count().catch(() => 0) === 0)
|
|
819
|
+
continue;
|
|
820
|
+
const value = await account.textContent({ timeout: 1000 }).catch(() => null);
|
|
821
|
+
const shown = value?.match(/[\w.+-]+@[\w.-]+\.[a-z]{2,}/i)?.[0]?.toLowerCase();
|
|
822
|
+
if (shown)
|
|
823
|
+
return shown === expected;
|
|
824
|
+
}
|
|
825
|
+
return false;
|
|
826
|
+
}
|
|
827
|
+
/** Microsoft's own sign-in page refusing a new prompt; see MFA_HELD_TEXT. */
|
|
828
|
+
async isHeldByMicrosoft(page) {
|
|
829
|
+
if (new URL(page.url()).hostname !== "login.microsoftonline.com")
|
|
830
|
+
return false;
|
|
831
|
+
return await page.getByText(MFA_HELD_TEXT).first().isVisible().catch(() => false);
|
|
832
|
+
}
|
|
742
833
|
/**
|
|
743
834
|
* Refuse to drive Entra's method pages, or type a code into them, unless
|
|
744
835
|
* this really is Microsoft showing the account we are signing in as. A code
|
package/build/auth/sso-flow.js
CHANGED
|
@@ -49,7 +49,7 @@ export class MfaApprovalError extends BrowserAuthError {
|
|
|
49
49
|
* else uses the default flow, which already covers the common Shibboleth,
|
|
50
50
|
* CAS, and Microsoft Entra forms.
|
|
51
51
|
*/
|
|
52
|
-
export function createSSOFlow(config, requestMfaCode, onMfaChallenge, onAutomaticPending) {
|
|
52
|
+
export function createSSOFlow(config, requestMfaCode, onMfaChallenge, onAutomaticPending, onMfaHeld) {
|
|
53
53
|
const credentials = {
|
|
54
54
|
username: config.username,
|
|
55
55
|
password: config.password,
|
|
@@ -67,6 +67,7 @@ export function createSSOFlow(config, requestMfaCode, onMfaChallenge, onAutomati
|
|
|
67
67
|
requestMfaCode,
|
|
68
68
|
onMfaChallenge,
|
|
69
69
|
onAutomaticPending,
|
|
70
|
+
onMfaHeld,
|
|
70
71
|
};
|
|
71
72
|
if (isTUDelftBrightspace(config.baseUrl)) {
|
|
72
73
|
return new TUDelftSSOFlow(credentials);
|
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, relayedLockLines } from "./auth/mfa-challenge.js";
|
|
15
|
+
import { AUTOMATIC_PENDING_MARKER, MFA_HELD_MARKER, challengeMarker, relayedLockLines } 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";
|
|
@@ -88,7 +88,10 @@ async function main() {
|
|
|
88
88
|
const onAutomaticPending = automatic
|
|
89
89
|
? () => console.log(AUTOMATIC_PENDING_MARKER)
|
|
90
90
|
: undefined;
|
|
91
|
-
|
|
91
|
+
// Microsoft refused a new prompt until an earlier one expires: the parent
|
|
92
|
+
// says so instead of "still starting". A terminal run reads the log line.
|
|
93
|
+
const onMfaHeld = automatic ? () => console.log(MFA_HELD_MARKER) : undefined;
|
|
94
|
+
await new BrowserAuth(config, { requestMfaCode: codePrompt, onMfaChallenge, onPhase, onAutomaticPending, onMfaHeld }).authenticate({
|
|
92
95
|
automatic,
|
|
93
96
|
onAuthenticated: async (token) => {
|
|
94
97
|
await tokenManager.setToken(token);
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { DEFAULT_CACHE_TTLS } from "../api/index.js";
|
|
7
7
|
import { fetchAllItems } from "../api/paginate.js";
|
|
8
8
|
import { GetAnnouncementsSchema, } from "./schemas.js";
|
|
9
|
-
import { toolResponse, toolResponseWithNotice, sanitizeError, isAuthUnavailable, authPendingNotice } from "./tool-helpers.js";
|
|
9
|
+
import { toolResponse, toolResponseWithNotice, sanitizeError, isAuthUnavailable, authPendingNotice, throwIfCredentialRejected } from "./tool-helpers.js";
|
|
10
10
|
import { log } from "../utils/logger.js";
|
|
11
11
|
import { applyCourseFilter } from "../utils/course-filter.js";
|
|
12
12
|
import { matchesModifiedSince } from "../utils/modified-since.js";
|
|
@@ -150,6 +150,7 @@ export function registerGetAnnouncements(server, apiClient, config) {
|
|
|
150
150
|
// answered, so the result says so instead of reporting zero
|
|
151
151
|
// announcements as if that were a real measurement.
|
|
152
152
|
if (isAuthUnavailable(error)) {
|
|
153
|
+
throwIfCredentialRejected(error);
|
|
153
154
|
log("DEBUG", `get_announcements: sign-in pending for course ${courseId}`, error);
|
|
154
155
|
const notice = "Sign-in to Brightspace is still in progress, so announcements for this course " +
|
|
155
156
|
`could not be fetched yet. ${authPendingNotice(error)} Call get_announcements again ` +
|
|
@@ -250,6 +251,8 @@ export function registerGetAnnouncements(server, apiClient, config) {
|
|
|
250
251
|
// always a bare array — a shape change would break every existing
|
|
251
252
|
// caller — so a pending course adds a second content block carrying
|
|
252
253
|
// the notice instead of changing content[0].
|
|
254
|
+
if (pending.length > 0)
|
|
255
|
+
throwIfCredentialRejected(pending[0].authError);
|
|
253
256
|
if (!modifiedSince) {
|
|
254
257
|
if (pending.length === 0)
|
|
255
258
|
return arrayPage(announcements, pagination);
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { DEFAULT_CACHE_TTLS } from "../api/index.js";
|
|
7
7
|
import { fetchAllItems, fetchAllObjects } from "../api/paginate.js";
|
|
8
8
|
import { GetAssignmentsSchema } from "./schemas.js";
|
|
9
|
-
import { toolResponse, sanitizeError, isAuthUnavailable, authPendingNotice } from "./tool-helpers.js";
|
|
9
|
+
import { toolResponse, sanitizeError, isAuthUnavailable, authPendingNotice, throwIfCredentialRejected } from "./tool-helpers.js";
|
|
10
10
|
import { convertHtmlToMarkdown } from "../utils/html-converter.js";
|
|
11
11
|
import { log } from "../utils/logger.js";
|
|
12
12
|
import { applyCourseFilter } from "../utils/course-filter.js";
|
|
@@ -892,6 +892,7 @@ export function registerGetAssignments(server, apiClient, config) {
|
|
|
892
892
|
// keep working — with authPending/notice added for callers that
|
|
893
893
|
// want to tell "no assignments" apart from "couldn't check".
|
|
894
894
|
if (isAuthUnavailable(error)) {
|
|
895
|
+
throwIfCredentialRejected(error);
|
|
895
896
|
log("DEBUG", `get_assignments: sign-in pending for course ${courseId}`, error);
|
|
896
897
|
return toolResponse({
|
|
897
898
|
courseId,
|
|
@@ -983,6 +984,7 @@ export function registerGetAssignments(server, apiClient, config) {
|
|
|
983
984
|
],
|
|
984
985
|
};
|
|
985
986
|
if (pending.length > 0) {
|
|
987
|
+
throwIfCredentialRejected(pending[0].authError);
|
|
986
988
|
response.authPending = true;
|
|
987
989
|
response.unavailableCourseIds = pending.map((c) => c.courseId);
|
|
988
990
|
response.notice =
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Licensed under MIT — see LICENSE file for details.
|
|
5
5
|
*/
|
|
6
6
|
import { GetCalendarEventsSchema } from "./schemas.js";
|
|
7
|
-
import { toolResponse, toolResponseWithNotice, errorResponse, sanitizeError, isAuthUnavailable, authPendingNotice } from "./tool-helpers.js";
|
|
7
|
+
import { toolResponse, toolResponseWithNotice, errorResponse, sanitizeError, isAuthUnavailable, authPendingNotice, throwIfCredentialRejected } from "./tool-helpers.js";
|
|
8
8
|
import { log } from "../utils/logger.js";
|
|
9
9
|
import { resolveCourses } from "./resolve-courses.js";
|
|
10
10
|
import { fetchCourseCalendarEvents } from "./calendar-events.js";
|
|
@@ -67,6 +67,7 @@ export function registerGetCalendarEvents(server, apiClient, config) {
|
|
|
67
67
|
// which names which course ids are unavailable.
|
|
68
68
|
const notices = [];
|
|
69
69
|
if (pendingCourseIds.length > 0) {
|
|
70
|
+
throwIfCredentialRejected(firstAuthError);
|
|
70
71
|
notices.push("Sign-in to Brightspace is still in progress, so calendar events for " +
|
|
71
72
|
`${pendingCourseIds.length} course(s) (${pendingCourseIds.join(", ")}) could not be fetched ` +
|
|
72
73
|
`yet. ${authPendingNotice(firstAuthError)} Call get_calendar_events again once sign-in finishes.`);
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { DEFAULT_CACHE_TTLS } from "../api/index.js";
|
|
7
7
|
import { fetchAllItems, fetchAllObjects } from "../api/paginate.js";
|
|
8
8
|
import { GetMyGradesSchema, } from "./schemas.js";
|
|
9
|
-
import { toolResponse, sanitizeError, errorResponse, isAuthUnavailable, authPendingNotice, } from "./tool-helpers.js";
|
|
9
|
+
import { toolResponse, sanitizeError, errorResponse, isAuthUnavailable, authPendingNotice, throwIfCredentialRejected, } from "./tool-helpers.js";
|
|
10
10
|
import { log } from "../utils/logger.js";
|
|
11
11
|
import { applyCourseFilter } from "../utils/course-filter.js";
|
|
12
12
|
import { gradebookUrl, quizUrl } from "../utils/deep-links.js";
|
|
@@ -115,6 +115,7 @@ export function registerGetMyGrades(server, apiClient, config) {
|
|
|
115
115
|
// working — with authPending/notice added for callers that want
|
|
116
116
|
// to tell "no grades" apart from "couldn't check".
|
|
117
117
|
if (isAuthUnavailable(error)) {
|
|
118
|
+
throwIfCredentialRejected(error);
|
|
118
119
|
log("DEBUG", `get_my_grades: sign-in pending for course ${courseId}`, error);
|
|
119
120
|
return toolResponse({
|
|
120
121
|
courseId,
|
|
@@ -260,6 +261,7 @@ export function registerGetMyGrades(server, apiClient, config) {
|
|
|
260
261
|
};
|
|
261
262
|
const notices = [];
|
|
262
263
|
if (pending.length > 0) {
|
|
264
|
+
throwIfCredentialRejected(pending[0].authError);
|
|
263
265
|
response.authPending = true;
|
|
264
266
|
response.unavailableCourseIds = pending.map((c) => c.courseId);
|
|
265
267
|
notices.push("Sign-in to Brightspace is still in progress, so grades for " +
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { DEFAULT_CACHE_TTLS } from "../api/index.js";
|
|
7
7
|
import { fetchAllObjects } from "../api/paginate.js";
|
|
8
8
|
import { GetRosterSchema, } from "./schemas.js";
|
|
9
|
-
import { toolResponse, sanitizeError, isAuthUnavailable, authPendingNotice } from "./tool-helpers.js";
|
|
9
|
+
import { toolResponse, sanitizeError, isAuthUnavailable, authPendingNotice, throwIfCredentialRejected } from "./tool-helpers.js";
|
|
10
10
|
import { log } from "../utils/logger.js";
|
|
11
11
|
// Purdue-specific role IDs. These are institution-specific values.
|
|
12
12
|
// If using at another institution, you may need to adjust these.
|
|
@@ -80,6 +80,8 @@ export function registerGetRoster(server, apiClient) {
|
|
|
80
80
|
// roster that lost its TAs to a pending sign-in should still show
|
|
81
81
|
// the instructor it already has.
|
|
82
82
|
authFailure = [instructorResult, taResult].find((r) => r.status === "rejected" && isAuthUnavailable(r.reason));
|
|
83
|
+
if (authFailure)
|
|
84
|
+
throwIfCredentialRejected(authFailure.reason);
|
|
83
85
|
// Merge results
|
|
84
86
|
if (instructorResult.status === "fulfilled") {
|
|
85
87
|
allUsers.push(...instructorResult.value);
|
|
@@ -169,6 +171,7 @@ export function registerGetRoster(server, apiClient) {
|
|
|
169
171
|
// authPending/notice added for callers that want to tell "empty
|
|
170
172
|
// roster" apart from "couldn't check".
|
|
171
173
|
if (isAuthUnavailable(error)) {
|
|
174
|
+
throwIfCredentialRejected(error);
|
|
172
175
|
log("DEBUG", `get_roster: sign-in pending for course ${courseId}`, error);
|
|
173
176
|
return toolResponse({
|
|
174
177
|
courseId,
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { DEFAULT_CACHE_TTLS } from "../api/index.js";
|
|
7
7
|
import { fetchAllObjects } from "../api/paginate.js";
|
|
8
8
|
import { GetUpcomingDueDatesSchema, } from "./schemas.js";
|
|
9
|
-
import { toolResponse, toolResponseWithNotice, sanitizeError, isAuthUnavailable, authPendingNotice } from "./tool-helpers.js";
|
|
9
|
+
import { toolResponse, toolResponseWithNotice, sanitizeError, isAuthUnavailable, authPendingNotice, throwIfCredentialRejected } from "./tool-helpers.js";
|
|
10
10
|
import { log } from "../utils/logger.js";
|
|
11
11
|
import { quizUrl, discussionUrl } from "../utils/deep-links.js";
|
|
12
12
|
import { assignmentLinkResolver } from "./assignment-links.js";
|
|
@@ -242,6 +242,7 @@ export function registerGetUpcomingDueDates(server, apiClient, config) {
|
|
|
242
242
|
if (pendingCourseIds.length === 0) {
|
|
243
243
|
return toolResponse(upcoming);
|
|
244
244
|
}
|
|
245
|
+
throwIfCredentialRejected(firstAuthError);
|
|
245
246
|
return toolResponseWithNotice(upcoming, "Sign-in to Brightspace is still in progress, so upcoming due dates for " +
|
|
246
247
|
`${pendingCourseIds.length} course(s) (${pendingCourseIds.join(", ")}) could not be fetched ` +
|
|
247
248
|
`yet. ${authPendingNotice(firstAuthError)} Call get_upcoming_due_dates again once sign-in finishes.`);
|
package/build/tools/sign-out.js
CHANGED
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
* Copyright (c) 2026 Rohan Muppa. All rights reserved.
|
|
4
4
|
* Licensed under MIT — see LICENSE file for details.
|
|
5
5
|
*/
|
|
6
|
+
import { stopAutomaticSignIn } from "../auth/auth-runner.js";
|
|
6
7
|
import { AuthenticationInProgressError } from "../auth/auth-lock.js";
|
|
7
8
|
import { clearSavedSession, RemoveError } from "../auth/logout.js";
|
|
9
|
+
import { authLockPath } from "../auth/mfa-challenge.js";
|
|
8
10
|
import { log } from "../utils/logger.js";
|
|
9
11
|
import { SignOutSchema } from "./schemas.js";
|
|
10
12
|
import { withUpdateNotice } from "./tool-helpers.js";
|
|
@@ -12,8 +14,11 @@ const KEPT = "Kept (if you set them up): your saved password, your settings, and
|
|
|
12
14
|
const OTHER_APPS = "Other Brightspace apps open on this computer are signed out too and will sign in again from scratch on their next question. An app you opened before updating to this version may keep answering for up to an hour; quit and reopen it to sign it out right away.";
|
|
13
15
|
const NEXT_SIGN_IN = "Because your saved password (and authenticator key, if you saved one) stay on this computer, that next sign-in may just ask you to approve it, or finish on its own if you saved an authenticator key. On a shared computer, also delete the saved \"brightspace-mcp-server\" entries from your computer's password store (Keychain Access on a Mac, Credential Manager on Windows).";
|
|
14
16
|
const LOCAL_ONLY = "This only clears this computer. It does not sign you out of Microsoft or Brightspace on other devices.";
|
|
15
|
-
const IN_PROGRESS = "Not signed out: a sign-in is in progress on this computer
|
|
17
|
+
const IN_PROGRESS = "Not signed out: a sign-in that sign_out can't cancel is in progress on this computer (one started in a terminal with `auth`, for example), so nothing was deleted and it was left running. Tell the user to finish or close that sign-in, then ask again; do not call sign_out again on your own.";
|
|
18
|
+
const CANCELLED = "Cancelled the Brightspace sign-in that was waiting for approval, so the prompt or number from it no longer works.";
|
|
19
|
+
const NEXT_QUESTION = "Microsoft keeps that prompt open for up to 5 minutes after sending it and sends this account no other until then, so the next Brightspace question can take that long to show a new number to approve.";
|
|
16
20
|
const ALREADY_SIGNED_OUT = "You're already signed out of Brightspace on this computer; there was no saved session to remove.";
|
|
21
|
+
const NOTHING_SAVED = "There was no saved session to remove.";
|
|
17
22
|
const UNEXPECTED_FAILURE = "Could not clear the saved session because of an unexpected error. Nothing more was changed; try again, or run `npx -y brightspace-mcp-server@latest auth --logout` in a terminal to see the details.";
|
|
18
23
|
function text(message, isError = false) {
|
|
19
24
|
const result = withUpdateNotice([{ type: "text", text: message }]);
|
|
@@ -22,47 +27,58 @@ function text(message, isError = false) {
|
|
|
22
27
|
/**
|
|
23
28
|
* Register sign_out tool.
|
|
24
29
|
*
|
|
25
|
-
* The in-chat version of `auth --logout
|
|
30
|
+
* The in-chat version of `auth --logout`, plus one thing the command does not
|
|
31
|
+
* do: it first cancels a background sign-in still waiting on MFA, this
|
|
32
|
+
* server's (AuthRunner.cancel, which answers every call waiting on it) or
|
|
33
|
+
* another app's on this computer (stopAutomaticSignIn). A user who missed the
|
|
34
|
+
* prompt otherwise waited out its 5-minute window for a new one. Then it
|
|
35
|
+
* deletes this computer's saved
|
|
26
36
|
* session for the account the server is configured with (clearSavedSession,
|
|
27
37
|
* under the sign-in lock, so a running sign-in is never disturbed), then
|
|
28
38
|
* empties the API client's response cache and this process's in-memory token
|
|
29
39
|
* so it serves nothing from the session it just signed out of. Answers are
|
|
30
|
-
* plain text, not JSON. A sign-in
|
|
31
|
-
* are ordinary answers rather than errors, so
|
|
32
|
-
* in a loop; only a file that could not be
|
|
40
|
+
* plain text, not JSON. A sign-in it cannot cancel (a person's `auth` run)
|
|
41
|
+
* and an env-supplied credential are ordinary answers rather than errors, so
|
|
42
|
+
* a model has no reason to retry in a loop; only a file that could not be
|
|
43
|
+
* deleted is reported as a failure.
|
|
33
44
|
*
|
|
34
45
|
* authRunner covers the moment between this server spawning a sign-in and
|
|
35
46
|
* that child taking the cross-process lock, when the lock alone still looks
|
|
36
47
|
* free. It is absent when the server uses a pasted credential (no runner).
|
|
37
48
|
*
|
|
38
|
-
* clear
|
|
49
|
+
* clear and stopElsewhere are injection seams for tests.
|
|
39
50
|
*/
|
|
40
|
-
export function registerSignOut(server, config, apiClient, tokenManager, authRunner, clear = clearSavedSession) {
|
|
51
|
+
export function registerSignOut(server, config, apiClient, tokenManager, authRunner, clear = clearSavedSession, stopElsewhere = (dir) => stopAutomaticSignIn(authLockPath(dir))) {
|
|
41
52
|
server.registerTool("sign_out", {
|
|
42
53
|
title: "Sign Out of Brightspace",
|
|
43
54
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
|
|
44
|
-
description: "Sign out of Brightspace on this computer: delete the saved Brightspace session, so every Brightspace app here must sign in again from scratch on its next question. Keeps the saved password, settings, and authenticator key. Does not sign out of Microsoft or Brightspace on other devices. Call this ONLY when the user explicitly asks to sign out or log out of Brightspace (a shared computer, handing the machine on). Never call it to fix an error,
|
|
55
|
+
description: "Sign out of Brightspace on this computer: cancel any sign-in still waiting for MFA approval here, then delete the saved Brightspace session, so every Brightspace app here must sign in again from scratch on its next question. Keeps the saved password, settings, and authenticator key. Does not sign out of Microsoft or Brightspace on other devices. Cancelling a waiting sign-in doesn't get a new prompt sooner: Microsoft sends no other prompt to the account until the one already sent expires, up to 5 minutes after it was sent. Call this ONLY when the user explicitly asks to sign out or log out of Brightspace (a shared computer, handing the machine on). Never call it on your own to fix an error, to get a new prompt, or as part of answering another question.",
|
|
45
56
|
inputSchema: SignOutSchema,
|
|
46
57
|
}, async () => {
|
|
47
58
|
if (config.envAccessToken || config.envSessionCookie) {
|
|
48
59
|
return text("Nothing was signed out. This server uses a credential from its configuration (D2L_ACCESS_TOKEN or D2L_SESSION_COOKIE, set in your AI app's client configuration or a .env file), and sign_out cannot remove that. To stop using it, remove that setting and restart the app.");
|
|
49
60
|
}
|
|
50
|
-
if (authRunner?.busy())
|
|
51
|
-
return text(IN_PROGRESS);
|
|
52
61
|
const dropInMemory = () => {
|
|
53
62
|
apiClient.clearCache();
|
|
54
63
|
tokenManager.forgetSession();
|
|
55
64
|
};
|
|
56
65
|
try {
|
|
66
|
+
// This server's own sign-in first: stopping it also answers its waiting calls.
|
|
67
|
+
let cancelled = (await authRunner?.cancel()) ?? false;
|
|
68
|
+
if (await stopElsewhere(config.sessionDir))
|
|
69
|
+
cancelled = true;
|
|
57
70
|
const { removed } = await clear(config.sessionDir);
|
|
58
71
|
dropInMemory();
|
|
59
|
-
if (removed.length === 0)
|
|
60
|
-
return text(ALREADY_SIGNED_OUT);
|
|
72
|
+
if (removed.length === 0) {
|
|
73
|
+
return text(cancelled ? [CANCELLED, NOTHING_SAVED, NEXT_QUESTION].join("\n") : ALREADY_SIGNED_OUT);
|
|
74
|
+
}
|
|
61
75
|
return text([
|
|
76
|
+
...(cancelled ? [CANCELLED] : []),
|
|
62
77
|
"Signed out of Brightspace on this computer.",
|
|
63
78
|
`Removed: ${removed.join(", ")}`,
|
|
64
79
|
KEPT,
|
|
65
80
|
OTHER_APPS,
|
|
81
|
+
...(cancelled ? [NEXT_QUESTION] : []),
|
|
66
82
|
NEXT_SIGN_IN,
|
|
67
83
|
LOCAL_ONLY,
|
|
68
84
|
].join("\n"));
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { ZodError } from "zod";
|
|
7
7
|
import { ApiError, RateLimitError, NetworkError } from "../api/index.js";
|
|
8
|
-
import { TokenRefreshError } from "../api/errors.js";
|
|
8
|
+
import { CredentialRejectedError, TokenRefreshError } from "../api/errors.js";
|
|
9
9
|
import { AuthProcessError } from "../auth/auth-runner.js";
|
|
10
10
|
import { log } from "../utils/logger.js";
|
|
11
11
|
import { AUTH_COMMAND } from "../utils/commands.js";
|
|
@@ -94,13 +94,36 @@ export function errorResponse(message, kind) {
|
|
|
94
94
|
*/
|
|
95
95
|
const MFA_SOLO_CALL = "Don't run other Brightspace calls in parallel until the sign-in completes: a batched " +
|
|
96
96
|
"answer can reach the user too late to approve. Make one call at a time.";
|
|
97
|
+
/**
|
|
98
|
+
* What a retry does to the prompt on the user's phone. Without this a model
|
|
99
|
+
* filled the gap itself, telling a user that a call timing out would make the
|
|
100
|
+
* server send a new push: the retry joins the background sign-in instead, and
|
|
101
|
+
* only a sign-in that is cancelled (sign_out) or left alone for ABANDON_MS
|
|
102
|
+
* (auth-runner.ts) is replaced.
|
|
103
|
+
*/
|
|
104
|
+
const MFA_SAME_PROMPT = "Calling again doesn't send a new prompt, and neither does a call timing out: the sign-in " +
|
|
105
|
+
"keeps waiting, for up to 5 minutes, on the prompt already sent.";
|
|
106
|
+
/** Entra number match only: purdue-sso.ts asks for another request when one expires or is denied. */
|
|
107
|
+
const MFA_NEW_NUMBER = "If Microsoft's number expires or is denied, the sign-in asks Microsoft for a new one by itself " +
|
|
108
|
+
"and the next answer shows it; always give the user the latest number.";
|
|
109
|
+
/**
|
|
110
|
+
* Why a missed prompt cannot be skipped. Microsoft keeps a prompt open for up
|
|
111
|
+
* to 5 minutes and refuses another for the same account until then
|
|
112
|
+
* (UserAuthFailedDuplicateRequest, seen live on Purdue's tenant 2026-10-25),
|
|
113
|
+
* so cancelling the sign-in only throws away the one that could finish.
|
|
114
|
+
*/
|
|
115
|
+
const MFA_NO_FASTER = "Cancelling the sign-in (sign_out) doesn't get one sooner: Microsoft sends no other prompt to " +
|
|
116
|
+
"this account until the one already sent expires, up to 5 minutes after it was sent.";
|
|
97
117
|
const AUTH_FAILURE_GUIDANCE = {
|
|
98
118
|
browserInstall: PLAYWRIGHT_INSTALL_HINT,
|
|
99
119
|
dependencyMissing: dependencyRepairHint(),
|
|
100
120
|
busy: "A sign-in is already running in another process. Let it finish, then try again, " +
|
|
101
121
|
`or run \`${AUTH_COMMAND}\` in a terminal to take over that background attempt.`,
|
|
102
|
-
cooldown: "Automatic sign-in is paused because an MFA prompt went unanswered
|
|
103
|
-
|
|
122
|
+
cooldown: "Automatic sign-in is paused because an MFA prompt went unanswered; the pause ends 5 minutes " +
|
|
123
|
+
"after that prompt was missed. Until then " +
|
|
124
|
+
"calls send no prompt and return this message; after that, the next call starts a new sign-in " +
|
|
125
|
+
"and sends a new prompt. " +
|
|
126
|
+
`To sign in now, run \`${AUTH_COMMAND}\` in a terminal (from your home folder); it shows the number to enter.`,
|
|
104
127
|
unsupported: "This login needs something your AI client cannot supply, usually a code from an "
|
|
105
128
|
+ "authenticator app. " +
|
|
106
129
|
`Run \`${AUTH_COMMAND}\` in a terminal (from your home folder) and sign in there.`,
|
|
@@ -108,7 +131,8 @@ const AUTH_FAILURE_GUIDANCE = {
|
|
|
108
131
|
"password could not be read. Unlock your keychain or keyring, then try again.",
|
|
109
132
|
transport: "Brightspace could not be reached to sign in. The saved session was kept. " +
|
|
110
133
|
"Check your connection and try again in a few minutes.",
|
|
111
|
-
timeout: "The sign-in did not finish in time, usually a missed MFA prompt
|
|
134
|
+
timeout: "The sign-in did not finish in time, usually a missed MFA prompt, and has stopped: the prompt it " +
|
|
135
|
+
"sent no longer works. The next call starts a new sign-in, or says automatic sign-in is paused. " +
|
|
112
136
|
`Run \`${AUTH_COMMAND}\` in a terminal (from your home folder) to complete it with the number visible.`,
|
|
113
137
|
failed: `The sign-in did not complete. Run \`${AUTH_COMMAND}\` in a terminal (from your home folder) to see why, ` +
|
|
114
138
|
"or `brightspace-setup` if your saved school or username is wrong.",
|
|
@@ -122,11 +146,22 @@ const AUTH_FAILURE_GUIDANCE = {
|
|
|
122
146
|
"again right away without waiting for them to " +
|
|
123
147
|
"confirm: each call waits up to 45 seconds for the approval and returns the result as soon " +
|
|
124
148
|
"as the sign-in completes. Keep calling until it succeeds or reports a different error. " +
|
|
125
|
-
MFA_SOLO_CALL
|
|
149
|
+
`${MFA_SAME_PROMPT} ${MFA_NO_FASTER} ${MFA_SOLO_CALL}`,
|
|
150
|
+
cancelled: "The sign-in was cancelled before it finished (by sign_out, or its process was stopped), so " +
|
|
151
|
+
"the prompt or number from it no longer works. Calling this tool again starts a new sign-in. " +
|
|
152
|
+
"Microsoft keeps the cancelled prompt open for up to 5 minutes after sending it and sends this " +
|
|
153
|
+
"account no other until then, so a new number can take that long; give the user the new number " +
|
|
154
|
+
"when an answer carries one.",
|
|
155
|
+
mfaHeld: "Microsoft is still holding an earlier sign-in prompt for this account (one that was missed, or " +
|
|
156
|
+
"whose sign-in was cancelled) and won't send a new prompt until that one expires, up to 5 minutes " +
|
|
157
|
+
"after it was sent. There is nothing to approve right now, and the earlier number no longer works. " +
|
|
158
|
+
"The sign-in asks Microsoft again every minute. Tell the user that, then call this tool again " +
|
|
159
|
+
"right away: each call waits up to 45 seconds and returns the new number as soon as Microsoft " +
|
|
160
|
+
"sends it. If a prompt the user didn't start shows on their phone, they should deny it.",
|
|
126
161
|
inProgress: "Brightspace sign-in is still starting in the background (opening the browser and the " +
|
|
127
162
|
"school's login pages). Call this tool again right away: the next call picks up that " +
|
|
128
|
-
"sign-in and returns the number to approve as soon as one appears.
|
|
129
|
-
"do anything yet.",
|
|
163
|
+
"sign-in and returns the number to approve as soon as one appears. No prompt has been sent " +
|
|
164
|
+
"yet, and calling again doesn't start a second sign-in. Don't ask the user to do anything yet.",
|
|
130
165
|
};
|
|
131
166
|
/**
|
|
132
167
|
* mfaPending is the one kind whose guidance is partly dynamic: `numberMatch`
|
|
@@ -167,7 +202,7 @@ function mfaPendingMessage(error) {
|
|
|
167
202
|
"tool again right away without waiting for them " +
|
|
168
203
|
"to confirm: each call waits up to 45 seconds for the approval and returns the result as " +
|
|
169
204
|
"soon as the sign-in completes. Keep calling until it succeeds or reports a different error. " +
|
|
170
|
-
MFA_SOLO_CALL
|
|
205
|
+
`${MFA_SAME_PROMPT} ${MFA_NEW_NUMBER} ${MFA_NO_FASTER} ${MFA_SOLO_CALL}`;
|
|
171
206
|
}
|
|
172
207
|
return AUTH_FAILURE_GUIDANCE[error.kind];
|
|
173
208
|
}
|
|
@@ -226,6 +261,16 @@ export function authPendingNotice(error) {
|
|
|
226
261
|
return "Authentication expired. Auto-reauthentication was attempted but failed. " +
|
|
227
262
|
`Please run \`${AUTH_COMMAND}\` in your terminal, then try again.`;
|
|
228
263
|
}
|
|
264
|
+
/**
|
|
265
|
+
* A rejected pasted credential (D2L_ACCESS_TOKEN or D2L_SESSION_COOKIE) never
|
|
266
|
+
* recovers by waiting, unlike a pending sign-in. Tools call this before
|
|
267
|
+
* reporting "sign-in is still in progress", so the whole call fails instead
|
|
268
|
+
* and sanitizeError tells the user how to replace the credential.
|
|
269
|
+
*/
|
|
270
|
+
export function throwIfCredentialRejected(error) {
|
|
271
|
+
if (error instanceof CredentialRejectedError)
|
|
272
|
+
throw error;
|
|
273
|
+
}
|
|
229
274
|
/**
|
|
230
275
|
* Sanitize errors for user-friendly messages
|
|
231
276
|
*
|
|
@@ -263,6 +308,9 @@ function describeError(error) {
|
|
|
263
308
|
if (error.status === 404) {
|
|
264
309
|
return errorResponse("Resource not found. The course or item may not exist, or you may not have access.");
|
|
265
310
|
}
|
|
311
|
+
if (error instanceof CredentialRejectedError) {
|
|
312
|
+
return errorResponse(error.guidance);
|
|
313
|
+
}
|
|
266
314
|
if (error.status === 401) {
|
|
267
315
|
// A stale install is a common cause of sign-in failing, so this is the
|
|
268
316
|
// one error worth attaching the update notice to.
|
|
@@ -103,7 +103,7 @@ function write(event, fields, context = calls.getStore(), idleMs, sinceSuccessMs
|
|
|
103
103
|
if (["success", "error", "sessionExpired", "transport"].includes(fields.outcome ?? ""))
|
|
104
104
|
record.outcome = fields.outcome;
|
|
105
105
|
if (["busy", "cooldown", "unsupported", "secureStorage", "transport", "timeout", "failed", "mfaPending",
|
|
106
|
-
"automaticPending", "inProgress"].includes(fields.reason ?? ""))
|
|
106
|
+
"automaticPending", "inProgress", "mfaHeld"].includes(fields.reason ?? ""))
|
|
107
107
|
record.reason = fields.reason;
|
|
108
108
|
if (AUTH_PHASES.includes(fields.phase ?? ""))
|
|
109
109
|
record.phase = fields.phase;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brightspace-mcp-server",
|
|
3
|
-
"version": "5.1.
|
|
3
|
+
"version": "5.1.21",
|
|
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",
|