brightspace-mcp-server 3.8.10 → 3.8.12

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.
@@ -24,6 +24,9 @@ const FIELD_POLL_MS = 250;
24
24
  const NUMBER_MATCH_SELECTOR = "#idRichContext_DisplaySign";
25
25
  const MFA_CODE_SELECTORS = ["#idTxtBx_SAOTCC_OTC", 'input[name="otc"]'];
26
26
  const MFA_CODE_SUBMIT_SELECTORS = ["#idSubmit_SAOTCC_Continue", "#idSIButton9"];
27
+ // Entra rejects a wrong code in place: the field stays and this message appears.
28
+ const MFA_CODE_ERROR_SELECTOR = "#idSpan_SAOTCC_Error_OTC";
29
+ const MAX_MFA_CODE_ATTEMPTS = 3;
27
30
  /**
28
31
  * Entra's "Don't ask again for N days" checkbox: the number-match page's id
29
32
  * first, then the verification-code page's, then the label text for a tenant
@@ -95,8 +98,15 @@ function signInName(username, baseUrl) {
95
98
  export class PurdueSSOFlow {
96
99
  config;
97
100
  accountHintSubmitted = false;
98
- /** One authenticator code per login. See submitMfaCode. */
99
- mfaCodeSubmitted = false;
101
+ /** Authenticator codes asked for during this login. See submitMfaCode. */
102
+ mfaCodeAttempts = 0;
103
+ /**
104
+ * True for exactly one poll after a code is resubmitted in response to a
105
+ * rejection. Entra's rejection span can still be in the DOM on that very
106
+ * next poll - it hasn't re-rendered yet, so what's on screen is still the
107
+ * PREVIOUS attempt's verdict, not this one's. See submitMfaCode.
108
+ */
109
+ awaitingMfaCodeSettle = false;
100
110
  /** Set once per login, the first time Entra's MFA page is handled. */
101
111
  rememberMfa;
102
112
  duoMfa;
@@ -446,18 +456,37 @@ export class PurdueSSOFlow {
446
456
  return false;
447
457
  if (this.config.headless === false)
448
458
  return false;
449
- // Ask once per login. This runs on every two-second poll, and Microsoft
450
- // commonly leaves the field on screen while it validates, so without this
451
- // a correct code gets a second prompt on the next tick. That prompt blocks
452
- // on stdin, and the deadline is only checked between iterations, so the
453
- // five-minute budget can never fire while parked there.
454
- if (this.mfaCodeSubmitted)
455
- return false;
459
+ // Ask again only after Entra's rejection message. This runs on every
460
+ // two-second poll, and Microsoft commonly leaves the field on screen while
461
+ // it validates, so a lingering field alone would give a correct code a
462
+ // second prompt on the next tick. That prompt blocks on stdin, and the
463
+ // deadline is only checked between iterations, so the five-minute budget
464
+ // can never fire while parked there.
465
+ const isRetry = this.mfaCodeAttempts > 0;
466
+ if (isRetry) {
467
+ // The rejection span can still be the stale one from the attempt we
468
+ // just resubmitted in reaction to - Entra hasn't re-rendered yet, so
469
+ // this poll can't tell that span apart from a genuine rejection of the
470
+ // new attempt. Give it exactly one more poll (like cuny-sso.ts requiring
471
+ // the URL to change before trusting its own error state) before trusting
472
+ // the span again.
473
+ if (this.awaitingMfaCodeSettle) {
474
+ this.awaitingMfaCodeSettle = false;
475
+ return false;
476
+ }
477
+ if (!await page.locator(MFA_CODE_ERROR_SELECTOR).first().isVisible().catch(() => false))
478
+ return false;
479
+ if (this.mfaCodeAttempts >= MAX_MFA_CODE_ATTEMPTS) {
480
+ throw new BrowserAuthError(`Microsoft rejected ${this.mfaCodeAttempts} authenticator codes. Run \`${AUTH_COMMAND}\` to try again.`, "mfa_code");
481
+ }
482
+ log("WARN", "Microsoft rejected that code. Enter the current one from your authenticator app.");
483
+ }
456
484
  if (!this.config.requestMfaCode) {
457
485
  throw new UnsupportedAuthenticationError(`This MFA method requires a code. Run \`${AUTH_COMMAND}\` in a terminal to enter it.`);
458
486
  }
459
487
  await this.rememberMfaDevice(page);
460
- this.mfaCodeSubmitted = true;
488
+ this.mfaCodeAttempts += 1;
489
+ this.awaitingMfaCodeSettle = isRetry;
461
490
  const code = await this.config.requestMfaCode();
462
491
  if (!/^\d{6,8}$/.test(code))
463
492
  throw new UnsupportedAuthenticationError("The MFA code must contain 6-8 digits.");
package/build/setup.js CHANGED
@@ -80,6 +80,34 @@ function ask(rl, question) {
80
80
  rl.question(question, (answer) => resolve(answer.trim()));
81
81
  });
82
82
  }
83
+ /**
84
+ * Apply one raw-mode stdin chunk to the password typed so far. A paste
85
+ * arrives as a single chunk, possibly wrapped in bracketed-paste markers and
86
+ * ending in a copied newline, so the chunk is read one character at a time
87
+ * and escape sequences (paste markers, arrow keys) never reach the password.
88
+ */
89
+ export function readPasswordInput(password, chunk) {
90
+ let echo = "";
91
+ const text = chunk.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "");
92
+ for (const ch of text) {
93
+ if (ch === "\x03")
94
+ return { password, echo, outcome: "cancel" };
95
+ if (ch === "\r" || ch === "\n")
96
+ return { password, echo, outcome: "submit" };
97
+ if (ch === "\x7f" || ch === "\b") {
98
+ if (password.length > 0) {
99
+ password = [...password].slice(0, -1).join("");
100
+ echo += "\b \b";
101
+ }
102
+ continue;
103
+ }
104
+ if (ch < " ")
105
+ continue;
106
+ password += ch;
107
+ echo += "*";
108
+ }
109
+ return { password, echo, outcome: "pending" };
110
+ }
83
111
  /**
84
112
  * Prompt for a password without echoing characters to the terminal.
85
113
  * We swap stdout.write to suppress the default echo, then print
@@ -114,37 +142,22 @@ function askPassword(prompt) {
114
142
  process.stdin.setRawMode?.(true);
115
143
  process.stdin.resume();
116
144
  const onData = (key) => {
117
- const ch = key.toString("utf-8");
118
- // Ctrl+C
119
- if (ch === "\x03") {
120
- process.stdout.write = origWrite;
121
- process.stdin.setRawMode?.(false);
122
- process.stdin.removeListener("data", onData);
123
- rl.close();
145
+ const input = readPasswordInput(password, key.toString("utf-8"));
146
+ if (input.echo)
147
+ origWrite(input.echo);
148
+ password = input.password;
149
+ if (input.outcome === "pending")
150
+ return;
151
+ process.stdout.write = origWrite;
152
+ process.stdin.setRawMode?.(false);
153
+ process.stdin.removeListener("data", onData);
154
+ rl.close();
155
+ if (input.outcome === "cancel") {
124
156
  console.log("");
125
157
  process.exit(0);
126
158
  }
127
- // Enter
128
- if (ch === "\r" || ch === "\n") {
129
- process.stdout.write = origWrite;
130
- process.stdin.setRawMode?.(false);
131
- process.stdin.removeListener("data", onData);
132
- rl.close();
133
- origWrite("\n");
134
- resolve(password);
135
- return;
136
- }
137
- // Backspace
138
- if (ch === "\x7f" || ch === "\b") {
139
- if (password.length > 0) {
140
- password = password.slice(0, -1);
141
- origWrite("\b \b");
142
- }
143
- return;
144
- }
145
- // Normal character
146
- password += ch;
147
- origWrite("*");
159
+ origWrite("\n");
160
+ resolve(password);
148
161
  };
149
162
  process.stdin.on("data", onData);
150
163
  });
@@ -406,7 +419,7 @@ async function main() {
406
419
  // Close the rl temporarily since askPassword manages its own
407
420
  rl.close();
408
421
  const passwordPrompt = preset
409
- ? `What is your ${preset.usernameLabel.replace("username", "password")}? `
422
+ ? `What is your ${preset.name} password? `
410
423
  : "What is your Brightspace password? ";
411
424
  let password = "";
412
425
  while (!password) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brightspace-mcp-server",
3
- "version": "3.8.10",
3
+ "version": "3.8.12",
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",