brightspace-mcp-server 3.7.1 → 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 CHANGED
@@ -105,6 +105,10 @@ 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
+
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.
111
+
108
112
  ## What You Can Ask About
109
113
 
110
114
  | Topic | Examples |
@@ -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
- return owner;
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 isDead(owner) {
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 lease = await acquireLease(lockPath, 0);
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 || !isDead(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", "Authentication already in progress in another process. Complete that attempt, then retry."],
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."],
@@ -19,7 +19,11 @@ const SILENT_SSO_POLL_MS = 1000;
19
19
  const INITIAL_NAVIGATION_TIMEOUT_MS = 60000;
20
20
  const VISIBLE_LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
21
21
  const SILENT_SSO = {
22
- emailFields: ["input[type=email]", "input[name=loginfmt]"],
22
+ // Kept in sync with purdue-sso.ts's EMAIL_SELECTORS: awaitSilentSSO gates on
23
+ // this list before ever calling into that file, so a two-step Shibboleth/CAS
24
+ // IdP whose username field only matches j_username or #signinid would never
25
+ // get past this gate otherwise.
26
+ emailFields: ["input[type=email]", "input[name=loginfmt]", "input[name=j_username]", "input#signinid"],
23
27
  credentialFields: ['input#username', 'input#userName', 'input[type="password"]'],
24
28
  mfaChallenges: ["#idRichContext_DisplaySign", "#idDiv_SAOTCAS_Title", "#idDiv_SAOTCC_Title"],
25
29
  campusSaml: 'a[href*="/d2l/lp/auth/saml/initiate-login"]',
@@ -64,7 +68,7 @@ export class BrowserAuth {
64
68
  }
65
69
  }
66
70
  async authenticate(options = {}) {
67
- 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" });
68
72
  try {
69
73
  // Even a cookie-only SAML redirect can issue an MFA push. Suppress all
70
74
  // automatic browser attempts during cooldown; HTTP token refresh runs
@@ -30,6 +30,8 @@ const PROMPT_SCOPE_SELECTORS = [
30
30
  "main",
31
31
  "body",
32
32
  ];
33
+ /** Duo's own passcode field, which its Universal Prompt labels inconsistently. */
34
+ const PASSCODE_INPUT_SELECTORS = ["#passcode-input", 'input[name="passcode"]'];
33
35
  /** The digits on a visible element, or null when it is absent or not digits. */
34
36
  async function readCodeFrom(target) {
35
37
  if (!await target.isVisible().catch(() => false))
@@ -53,31 +55,109 @@ export class DuoMfaHandler {
53
55
  approvalAnnounced = false;
54
56
  verificationCodeAnnounced = null;
55
57
  passcodeSubmitted = false;
58
+ /** True once Duo's remembered-device question has been answered this login. */
59
+ deviceQuestionAnswered = false;
60
+ /** Step guards for walking Duo's "Other options" menu to the passcode field. */
61
+ otherOptionsClicked = false;
62
+ passcodeChoiceClicked = false;
56
63
  /** True once onMfaChallenge has been told about this login, code or not. */
57
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;
58
67
  constructor(options) {
59
68
  this.options = options;
60
69
  }
61
70
  isChallenge(page) {
62
71
  return isDuoPrompt(page);
63
72
  }
73
+ /**
74
+ * Some tenants gate the push behind a remembered-device question ("Is this
75
+ * your device?") that nothing proceeds past until it is answered. A headless
76
+ * run has nobody to click it, so the whole login stalls until the five-minute
77
+ * MFA deadline expires. Answering yes also makes Duo remember this device,
78
+ * which is what an unattended client wants.
79
+ */
80
+ async answerDeviceQuestion(page) {
81
+ if (this.deviceQuestionAnswered)
82
+ return false;
83
+ const candidates = [
84
+ page.getByRole("button", { name: /yes.*this is my device/i }).first(),
85
+ page.locator("button", { hasText: /yes.*this is my device/i }).first(),
86
+ ];
87
+ for (const candidate of candidates) {
88
+ if (!await candidate.isVisible().catch(() => false))
89
+ continue;
90
+ this.deviceQuestionAnswered = true;
91
+ log("INFO", "Answered Duo's remembered-device question with yes.");
92
+ await candidate.click().catch(() => { });
93
+ return true;
94
+ }
95
+ return false;
96
+ }
97
+ /** Duo's passcode field, whichever of its shapes is on screen. */
98
+ async passcodeInput(page) {
99
+ for (const selector of PASSCODE_INPUT_SELECTORS) {
100
+ const target = page.locator(selector).first();
101
+ if (await target.isVisible().catch(() => false))
102
+ return target;
103
+ }
104
+ const byRole = page.getByRole("textbox", { name: /passcode|verification code/i }).first();
105
+ return await byRole.isVisible().catch(() => false) ? byRole : null;
106
+ }
107
+ /**
108
+ * Duo opens on "Check for a Duo Push" and keeps the passcode field behind its
109
+ * "Other options" menu ("Select an option to log in" -> "Duo Mobile
110
+ * passcode"). Walking that path makes sign-in independent of push delivery
111
+ * and of the device Duo happens to pick. Each step is clicked at most once,
112
+ * so a re-render cannot bounce the page back and forth.
113
+ */
114
+ async openPasscodeEntry(page) {
115
+ if (await this.passcodeInput(page))
116
+ return;
117
+ if (!this.otherOptionsClicked) {
118
+ const other = page.getByRole("button", { name: /other options/i }).first();
119
+ if (await other.isVisible().catch(() => false)) {
120
+ this.otherOptionsClicked = true;
121
+ log("INFO", "Opening Duo's Other options menu for a passcode.");
122
+ await other.click().catch(() => { });
123
+ await page.waitForTimeout(1500);
124
+ }
125
+ }
126
+ if (this.passcodeChoiceClicked)
127
+ return;
128
+ const choice = page.getByRole("button", { name: /duo mobile passcode/i }).first();
129
+ if (await choice.isVisible().catch(() => false)) {
130
+ this.passcodeChoiceClicked = true;
131
+ log("INFO", "Choosing Duo Mobile passcode entry.");
132
+ await choice.click().catch(() => { });
133
+ await page.waitForTimeout(1500);
134
+ }
135
+ }
64
136
  /** Returns true while the page is on a Duo challenge. */
65
137
  async handle(page) {
66
138
  if (!this.isChallenge(page))
67
139
  return false;
140
+ // Answer before announcing the push: on a gated tenant no push is sent
141
+ // until this is answered, so announcing first would be a lie.
142
+ await this.answerDeviceQuestion(page);
143
+ // Opt-in, because it replaces a push with a typed code: without it a tenant
144
+ // whose pushes work keeps the zero-touch path.
145
+ if (process.env.D2L_DUO_PASSCODE)
146
+ await this.openPasscodeEntry(page);
68
147
  const verificationCode = await this.readVerificationCode(page);
69
148
  if (!this.approvalAnnounced) {
70
149
  this.approvalAnnounced = true;
71
150
  log("WARN", "Waiting up to 5 minutes for Duo MFA approval on your device.");
72
151
  this.options.onMfaChallenge?.(verificationCode);
73
- if (verificationCode)
74
- this.announcedToCaller = true;
152
+ this.announcedToCaller = true;
153
+ this.lastAnnouncedCode = verificationCode;
75
154
  }
76
155
  if (verificationCode && verificationCode !== this.verificationCodeAnnounced) {
77
156
  this.verificationCodeAnnounced = verificationCode;
78
157
  log("WARN", `Duo verification code: ${verificationCode}. Enter it in Duo Mobile.`);
79
- if (!this.announcedToCaller) {
158
+ if (!this.announcedToCaller || verificationCode !== this.lastAnnouncedCode) {
80
159
  this.announcedToCaller = true;
160
+ this.lastAnnouncedCode = verificationCode;
81
161
  this.options.onMfaChallenge?.(verificationCode);
82
162
  }
83
163
  }
@@ -126,8 +206,8 @@ export class DuoMfaHandler {
126
206
  async submitPasscode(page) {
127
207
  if (this.options.headless === false || this.passcodeSubmitted)
128
208
  return;
129
- const input = page.getByRole("textbox", { name: /passcode|verification code/i }).first();
130
- if (!await input.isVisible().catch(() => false))
209
+ const input = await this.passcodeInput(page);
210
+ if (!input)
131
211
  return;
132
212
  if (!this.options.requestMfaCode) {
133
213
  throw new UnsupportedAuthenticationError(`Duo requires a passcode. Run \`${AUTH_COMMAND}\` in a terminal to enter it.`);
@@ -8,7 +8,9 @@ import { log } from "../utils/logger.js";
8
8
  import { MfaApprovalError, UnsupportedAuthenticationError } from "./sso-flow.js";
9
9
  import { DuoMfaHandler } from "./duo-mfa.js";
10
10
  import { AUTH_COMMAND } from "../utils/commands.js";
11
- const EMAIL_SELECTORS = ["input[type=email]", "input[name=loginfmt]"];
11
+ // Entra names its username field type=email/loginfmt; Shibboleth portals (USC's
12
+ // login.usc.edu among them) use the protocol's j_username.
13
+ const EMAIL_SELECTORS = ["input[type=email]", "input[name=loginfmt]", "input[name=j_username]", "input#signinid"];
12
14
  const PASSWORD_SELECTORS = ["input[type=password]", "input[name=passwd]"];
13
15
  const SUBMIT_SELECTORS = ["#idSIButton9", "input[type=submit]", "button[type=submit]"];
14
16
  const FIELD_TIMEOUT_MS = 30_000;
@@ -26,6 +28,48 @@ const MFA_CODE_SUBMIT_SELECTORS = ["#idSubmit_SAOTCC_Continue", "#idSIButton9"];
26
28
  const NUMBER_MATCH_POLL_MS = 2000;
27
29
  /** A person has to find their phone, unlock it, and read a prompt. */
28
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;
29
73
  /** Microsoft expects Purdue's full sign-in name, while setup also accepts a career account. */
30
74
  function signInName(username, baseUrl) {
31
75
  const isPurdue = baseUrl && new URL(baseUrl).hostname.toLowerCase() === "purdue.brightspace.com";
@@ -57,6 +101,14 @@ export class PurdueSSOFlow {
57
101
  const email = signInName(this.config.username, this.config.baseUrl);
58
102
  if (!await this.fillWhenReady(page, EMAIL_SELECTORS, email))
59
103
  return false;
104
+ // Reached via awaitSilentSSO, a single-page IdP (see enterCredentials)
105
+ // renders its password field next to the username too. Clicking submit
106
+ // without filling it first would post an empty password and burn the
107
+ // attempt, so detect and handle it here the same way.
108
+ if (this.config.password && await this.hasCoVisiblePassword(page)) {
109
+ if (!await this.fillWhenReady(page, PASSWORD_SELECTORS, this.config.password))
110
+ return false;
111
+ }
60
112
  if (!await this.clickWhenReady(page, SUBMIT_SELECTORS))
61
113
  return false;
62
114
  this.accountHintSubmitted = true;
@@ -125,17 +177,34 @@ export class PurdueSSOFlow {
125
177
  if (!hintAccepted) {
126
178
  const email = signInName(this.config.username, this.config.baseUrl);
127
179
  if (!await this.fillWhenReady(page, EMAIL_SELECTORS, email)) {
128
- throw new UnsupportedAuthenticationError("The Microsoft email field did not appear. Automatic sign-in cannot continue.");
180
+ throw new UnsupportedAuthenticationError("The identity provider's username field did not appear. Automatic sign-in cannot continue.");
181
+ }
182
+ // A single-page identity provider (Shibboleth portals such as USC's
183
+ // login.usc.edu) renders the password field next to the username. Clicking
184
+ // submit between the two would post an empty password and spend the
185
+ // attempt, so fill both and click once. Entra can also flash a
186
+ // password-shaped decoy for a single instant while its email view is
187
+ // still initializing (see awaitSilentSSO's passwordPromptPolls in
188
+ // browser-auth.ts), so this only takes the single-page branch once the
189
+ // field survives two consecutive checks.
190
+ if (await this.hasCoVisiblePassword(page)) {
191
+ if (!await this.fillWhenReady(page, PASSWORD_SELECTORS, this.config.password)) {
192
+ throw new UnsupportedAuthenticationError("The identity provider's password field did not appear. Automatic sign-in cannot continue.");
193
+ }
194
+ if (!await this.clickWhenReady(page, SUBMIT_SELECTORS)) {
195
+ throw new UnsupportedAuthenticationError("The identity provider's submit button did not appear. Automatic sign-in cannot continue.");
196
+ }
197
+ return;
129
198
  }
130
199
  if (!await this.clickWhenReady(page, SUBMIT_SELECTORS)) {
131
- throw new UnsupportedAuthenticationError("The Microsoft email submit button did not appear. Automatic sign-in cannot continue.");
200
+ throw new UnsupportedAuthenticationError("The identity provider's username submit button did not appear. Automatic sign-in cannot continue.");
132
201
  }
133
202
  }
134
203
  if (!await this.fillWhenReady(page, PASSWORD_SELECTORS, this.config.password)) {
135
- throw new UnsupportedAuthenticationError("The Microsoft password field did not appear. Automatic sign-in cannot continue.");
204
+ throw new UnsupportedAuthenticationError("The identity provider's password field did not appear. Automatic sign-in cannot continue.");
136
205
  }
137
206
  if (!await this.clickWhenReady(page, SUBMIT_SELECTORS)) {
138
- throw new UnsupportedAuthenticationError("The Microsoft password submit button did not appear. Automatic sign-in cannot continue.");
207
+ throw new UnsupportedAuthenticationError("The identity provider's password submit button did not appear. Automatic sign-in cannot continue.");
139
208
  }
140
209
  }
141
210
  /** Ported from Brightspace Bar's proven four-step Entra choreography. */
@@ -168,6 +237,22 @@ export class PurdueSSOFlow {
168
237
  }
169
238
  return false;
170
239
  }
240
+ /**
241
+ * True only when the password field is visible on two consecutive checks.
242
+ * Entra can transiently show a password-shaped control for a single
243
+ * instant while its email view is still initializing (documented next to
244
+ * awaitSilentSSO's `passwordPromptPolls` in browser-auth.ts); trusting one
245
+ * instantaneous observation can fill that decoy and click Next, leaving
246
+ * Entra's real password page never filled. A genuinely single-page IdP
247
+ * (Shibboleth portals such as USC's login.usc.edu) keeps the field on
248
+ * screen, so it survives the second check.
249
+ */
250
+ async hasCoVisiblePassword(page) {
251
+ if (!await this.anyVisible(page, PASSWORD_SELECTORS))
252
+ return false;
253
+ await page.waitForTimeout(FIELD_POLL_MS);
254
+ return await this.anyVisible(page, PASSWORD_SELECTORS);
255
+ }
171
256
  async hasPostCredentialChallenge(page) {
172
257
  return this.duoMfa.isChallenge(page) || await this.anyVisible(page, [
173
258
  NUMBER_MATCH_SELECTOR,
@@ -186,6 +271,20 @@ export class PurdueSSOFlow {
186
271
  let announced = null;
187
272
  /** True once onMfaChallenge has been told about this login, number or not. */
188
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;
189
288
  try {
190
289
  while (Date.now() < deadline) {
191
290
  if (await this.duoMfa.handle(page))
@@ -193,6 +292,8 @@ export class PurdueSSOFlow {
193
292
  if (await this.submitMfaCode(page))
194
293
  challenged = true;
195
294
  const number = await this.readNumberMatch(page);
295
+ if (number)
296
+ sawNumber = true;
196
297
  const challengeVisible = number !== null ||
197
298
  await page.locator("#idDiv_SAOTCAS_Title").first().isVisible().catch(() => false) ||
198
299
  await page.locator("#idDiv_SAOTCC_Title").first().isVisible().catch(() => false);
@@ -200,22 +301,50 @@ export class PurdueSSOFlow {
200
301
  challenged = true;
201
302
  log("WARN", "Waiting up to 5 minutes for Microsoft MFA approval on your device.");
202
303
  this.config.onMfaChallenge?.(number);
203
- if (number)
204
- announcedToCaller = true;
304
+ announcedToCaller = true;
305
+ lastAnnouncedNumber = number;
205
306
  }
206
307
  if (number && number !== announced) {
207
308
  announced = number;
208
309
  log("WARN", `Number match: ${number}. Enter it in Microsoft Authenticator.`);
209
- if (!announcedToCaller) {
310
+ if (!announcedToCaller || number !== lastAnnouncedNumber) {
210
311
  announcedToCaller = true;
312
+ lastAnnouncedNumber = number;
211
313
  this.config.onMfaChallenge?.(number);
212
314
  }
213
315
  }
316
+ if (number) {
317
+ numberVanishedAt = null;
318
+ resendNotFoundWarned = false;
319
+ }
214
320
  if (await this.isAuthenticated(page)) {
215
321
  log("INFO", "Login successful - verified Brightspace home");
216
322
  return;
217
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
+ }
218
344
  await this.clickProvenKmsi(page);
345
+ // The federated-domain trust prompt arrives after the IdP succeeds, so
346
+ // it has to be caught by this loop rather than by enterCredentials.
347
+ await this.clickTrustPrompt(page);
219
348
  await page.waitForTimeout(NUMBER_MATCH_POLL_MS);
220
349
  }
221
350
  }
@@ -230,6 +359,53 @@ export class PurdueSSOFlow {
230
359
  throw new MfaApprovalError(undefined, announced ?? undefined);
231
360
  throw new UnsupportedAuthenticationError("Sign-in did not reach a supported MFA challenge or Brightspace within 5 minutes.");
232
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
+ }
233
409
  async submitMfaCode(page) {
234
410
  const input = await this.firstVisible(page, MFA_CODE_SELECTORS);
235
411
  if (!input)
@@ -301,6 +477,60 @@ export class PurdueSSOFlow {
301
477
  log("DEBUG", 'Clicked Yes on "Stay signed in?"');
302
478
  }
303
479
  }
480
+ /**
481
+ * Microsoft asks users of a federated domain to confirm they trust it ("Do
482
+ * you trust usc.edu?") before issuing the SAML assertion to Brightspace.
483
+ * Nothing proceeds until Continue is clicked, and a headless run has nobody
484
+ * to click it, so the flow parks on this page until the MFA deadline and
485
+ * the session is never established. This dialog is an anti-login-CSRF
486
+ * control, not a nuisance interstitial, so it is not enough to notice the
487
+ * text is present somewhere on the page (`page.getByText()` is a whole-page
488
+ * substring search, not a heading-scoped match) — the domain named in the
489
+ * prompt is parsed out and compared against the domain this login is
490
+ * actually signing into. A mismatch (e.g. the browser was steered to a
491
+ * different tenant's confirmation) is left unclicked; the existing 5-minute
492
+ * MFA timeout is the safe failure mode for that, same as any other
493
+ * unhandled prompt.
494
+ */
495
+ async clickTrustPrompt(page) {
496
+ if (new URL(page.url()).hostname !== "login.microsoftonline.com")
497
+ return;
498
+ const prompt = page.getByText(/Do you trust/i).first();
499
+ if (!await prompt.isVisible().catch(() => false))
500
+ return;
501
+ const text = await prompt.textContent().catch(() => null);
502
+ const promptDomain = text ? this.extractTrustDomain(text) : null;
503
+ const expectedDomain = this.expectedTrustDomain();
504
+ if (!promptDomain || !expectedDomain || promptDomain !== expectedDomain) {
505
+ log("WARN", `Domain-trust prompt named "${promptDomain ?? "an unknown domain"}", which does not match the configured sign-in domain; not confirming trust automatically.`);
506
+ return;
507
+ }
508
+ const cont = page.getByRole("button", { name: /continue/i }).first();
509
+ if (!await cont.isVisible().catch(() => false))
510
+ return;
511
+ await cont.click().catch(() => { });
512
+ log("INFO", `Clicked Continue on Microsoft's domain-trust prompt for ${promptDomain}.`);
513
+ }
514
+ /** Pulls the domain Microsoft named out of "Do you trust <domain>?" text. */
515
+ extractTrustDomain(text) {
516
+ const match = text.match(/do you trust\s+([a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+)/i);
517
+ return match ? match[1].toLowerCase() : null;
518
+ }
519
+ /** The domain this login is actually signing into, to check the trust prompt against. */
520
+ expectedTrustDomain() {
521
+ if (this.config.username) {
522
+ const email = signInName(this.config.username, this.config.baseUrl);
523
+ const at = email.lastIndexOf("@");
524
+ if (at !== -1)
525
+ return email.slice(at + 1).toLowerCase();
526
+ }
527
+ // Purdue's own tenant is federated to purdue.edu regardless of what the
528
+ // configured username looks like.
529
+ if (this.config.baseUrl && new URL(this.config.baseUrl).hostname.toLowerCase() === "purdue.brightspace.com") {
530
+ return "purdue.edu";
531
+ }
532
+ return null;
533
+ }
304
534
  /** The digits on screen, or null when Entra is not showing any. */
305
535
  async readNumberMatch(page) {
306
536
  const sign = page.locator(NUMBER_MATCH_SELECTOR).first();
@@ -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.1",
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",