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 +4 -0
- package/build/auth/auth-lock.js +83 -10
- package/build/auth/auth-runner.js +1 -1
- package/build/auth/browser-auth.js +6 -2
- package/build/auth/duo-mfa.js +85 -5
- package/build/auth/purdue-sso.js +238 -8
- package/build/tools/tool-helpers.js +2 -1
- package/package.json +1 -1
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 |
|
package/build/auth/auth-lock.js
CHANGED
|
@@ -48,15 +48,20 @@ export class AuthenticationInProgressError extends Error {
|
|
|
48
48
|
async function readOwner(lockPath) {
|
|
49
49
|
try {
|
|
50
50
|
const owner = JSON.parse(await fs.readFile(path.join(lockPath, "owner.json"), "utf8"));
|
|
51
|
-
if (Number.isSafeInteger(owner.pid) && owner.pid > 0 && typeof owner.host === "string" && typeof owner.nonce === "string")
|
|
52
|
-
|
|
51
|
+
if (Number.isSafeInteger(owner.pid) && owner.pid > 0 && typeof owner.host === "string" && typeof owner.nonce === "string") {
|
|
52
|
+
// A lock file written before `mode` existed, or one that carries an
|
|
53
|
+
// unrecognized value, is treated as "explicit" — the safer default,
|
|
54
|
+
// since only an "automatic" owner is ever eligible to be taken over.
|
|
55
|
+
const mode = owner.mode === "automatic" ? "automatic" : "explicit";
|
|
56
|
+
return { pid: owner.pid, host: owner.host, nonce: owner.nonce, mode };
|
|
57
|
+
}
|
|
53
58
|
}
|
|
54
59
|
catch {
|
|
55
60
|
// An incomplete or unknown lock is not proof that its owner is gone.
|
|
56
61
|
}
|
|
57
62
|
return undefined;
|
|
58
63
|
}
|
|
59
|
-
function
|
|
64
|
+
function isDeadImpl(owner) {
|
|
60
65
|
// Session directories are local-only. DHCP can change the host's name
|
|
61
66
|
// without changing this process table, so only PID liveness decides.
|
|
62
67
|
try {
|
|
@@ -67,6 +72,20 @@ function isDead(owner) {
|
|
|
67
72
|
return error.code === "ESRCH";
|
|
68
73
|
}
|
|
69
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* Process operations behind a swappable seam. Production code always goes
|
|
77
|
+
* through this object with its real implementations; a test that needs a
|
|
78
|
+
* "live" owner it can safely terminate (without spawning or signaling a real
|
|
79
|
+
* process) replaces `kill`/`isDead` here instead. This file is intentionally
|
|
80
|
+
* free of relative imports — see tests/auth/auth-lock.test.ts, which loads it
|
|
81
|
+
* standalone via a data: URL for its real-process tests, and a relative
|
|
82
|
+
* import cannot be resolved from that base — so this seam is a plain
|
|
83
|
+
* exported object rather than an injected logger or similar dependency.
|
|
84
|
+
*/
|
|
85
|
+
export const lockOps = {
|
|
86
|
+
kill: (pid, signal) => { process.kill(pid, signal); },
|
|
87
|
+
isDead: isDeadImpl,
|
|
88
|
+
};
|
|
70
89
|
/** Remove only our fixed metadata names, after the directory was retired. */
|
|
71
90
|
async function cleanRetired(lockPath) {
|
|
72
91
|
await fs.unlink(path.join(lockPath, "owner.json")).catch(() => { });
|
|
@@ -103,16 +122,56 @@ class Lease {
|
|
|
103
122
|
await cleanRetired(this.directory);
|
|
104
123
|
}
|
|
105
124
|
}
|
|
125
|
+
/**
|
|
126
|
+
* How long an explicit run waits, after signaling a live automatic owner,
|
|
127
|
+
* for that owner to actually release the lock (either by dying outright or
|
|
128
|
+
* by finishing its own cleanup and renaming the directory away) before
|
|
129
|
+
* retrying acquisition.
|
|
130
|
+
*/
|
|
131
|
+
const TAKEOVER_WAIT_MS = 10_000;
|
|
132
|
+
const TAKEOVER_POLL_MS = 100;
|
|
133
|
+
/**
|
|
134
|
+
* SIGTERM a live automatic owner and wait for it to actually give up the
|
|
135
|
+
* lock. browser-auth.ts's `closeOnSignal` handles SIGTERM by closing the
|
|
136
|
+
* browser, which makes the automatic run's in-flight MFA loop throw; its
|
|
137
|
+
* `authenticate()` then runs its `finally` and releases the lock the normal
|
|
138
|
+
* way (the directory is renamed away). If the child instead dies outright
|
|
139
|
+
* (already-registered signal handler races the browser launch, or the
|
|
140
|
+
* process is killed some other way), `isDead` catches that instead. Either
|
|
141
|
+
* way this resolves once the lock is free for `acquireLease` to retry, or
|
|
142
|
+
* once TAKEOVER_WAIT_MS has passed without either happening.
|
|
143
|
+
*/
|
|
144
|
+
async function waitForTakeover(lockPath, stale) {
|
|
145
|
+
try {
|
|
146
|
+
lockOps.kill(stale.pid, "SIGTERM");
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
// The owner exited between the liveness check and the signal. That is the
|
|
150
|
+
// outcome a takeover wants; the wait below sees the dead PID and returns.
|
|
151
|
+
if (error.code !== "ESRCH")
|
|
152
|
+
throw error;
|
|
153
|
+
}
|
|
154
|
+
const deadline = Date.now() + TAKEOVER_WAIT_MS;
|
|
155
|
+
while (Date.now() < deadline) {
|
|
156
|
+
if (lockOps.isDead(stale))
|
|
157
|
+
return;
|
|
158
|
+
const current = await readOwner(lockPath);
|
|
159
|
+
if (!current || current.nonce !== stale.nonce)
|
|
160
|
+
return;
|
|
161
|
+
await delay(TAKEOVER_POLL_MS);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
106
164
|
/** Acquire a process-shared lock without waiting or relying on its age. */
|
|
107
|
-
export async function acquireProcessLock(lockPath) {
|
|
165
|
+
export async function acquireProcessLock(lockPath, options = {}) {
|
|
108
166
|
await fs.mkdir(path.dirname(lockPath), { recursive: true, mode: 0o700 });
|
|
109
|
-
const
|
|
167
|
+
const mode = options.mode ?? "explicit";
|
|
168
|
+
const lease = await acquireLease(lockPath, 0, mode);
|
|
110
169
|
return () => lease.release();
|
|
111
170
|
}
|
|
112
|
-
async function acquireLease(lockPath, depth) {
|
|
171
|
+
async function acquireLease(lockPath, depth, mode) {
|
|
113
172
|
if (depth > 16)
|
|
114
173
|
throw new AuthenticationInProgressError();
|
|
115
|
-
const owner = { pid: process.pid, host: hostname(), nonce: randomUUID() };
|
|
174
|
+
const owner = { pid: process.pid, host: hostname(), nonce: randomUUID(), mode };
|
|
116
175
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
117
176
|
try {
|
|
118
177
|
await fs.mkdir(lockPath, { mode: 0o700 });
|
|
@@ -132,15 +191,29 @@ async function acquireLease(lockPath, depth) {
|
|
|
132
191
|
throw error;
|
|
133
192
|
}
|
|
134
193
|
const stale = await readOwner(lockPath);
|
|
135
|
-
if (!stale
|
|
194
|
+
if (!stale)
|
|
136
195
|
throw new AuthenticationInProgressError();
|
|
196
|
+
if (!lockOps.isDead(stale)) {
|
|
197
|
+
// A live owner is not stale — the existing behavior for two contending
|
|
198
|
+
// explicit runs, or an automatic run finding anything alive at all.
|
|
199
|
+
// The one exception: an explicit run may take over a live *automatic*
|
|
200
|
+
// one, since a background sign-in nobody is watching should not block
|
|
201
|
+
// someone who just sat down to fix it in a terminal.
|
|
202
|
+
if (mode !== "explicit" || stale.mode !== "automatic")
|
|
203
|
+
throw new AuthenticationInProgressError();
|
|
204
|
+
// No import of the shared logger here — see lockOps's doc comment on
|
|
205
|
+
// why this file stays free of relative imports.
|
|
206
|
+
console.error(`[${new Date().toISOString()}] [WARN] Took over a background sign-in (pid ${stale.pid}) for an explicit auth run`);
|
|
207
|
+
await waitForTakeover(lockPath, stale);
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
137
210
|
// Recovery gets its own process lock within the stale directory. If a
|
|
138
211
|
// recovering process crashes, the same dead-owner rules recover its lock.
|
|
139
212
|
// Keep this claim in the directory during rename to serialize contenders.
|
|
140
|
-
const claim = await acquireLease(path.join(lockPath, "reclaim.lock"), depth + 1);
|
|
213
|
+
const claim = await acquireLease(path.join(lockPath, "reclaim.lock"), depth + 1, mode);
|
|
141
214
|
try {
|
|
142
215
|
const current = await readOwner(lockPath);
|
|
143
|
-
if (current?.nonce !== stale.nonce || !isDead(current))
|
|
216
|
+
if (current?.nonce !== stale.nonce || !lockOps.isDead(current))
|
|
144
217
|
throw new AuthenticationInProgressError();
|
|
145
218
|
const retired = `${lockPath}.stale.${owner.nonce}`;
|
|
146
219
|
// A rename blocked by contention means another contender is inside this
|
|
@@ -408,7 +408,7 @@ export class AuthRunner {
|
|
|
408
408
|
}
|
|
409
409
|
else {
|
|
410
410
|
const failures = {
|
|
411
|
-
2: ["busy",
|
|
411
|
+
2: ["busy", `Authentication already in progress in another process. Complete that attempt, then retry, or run \`${AUTH_COMMAND}\` in a terminal to take over that background attempt.`],
|
|
412
412
|
3: ["cooldown", `Automatic MFA is paused after an unsuccessful attempt. Run ${AUTH_COMMAND} to retry immediately.`],
|
|
413
413
|
4: ["unsupported", "This identity provider cannot complete headless authentication. See the authentication logs."],
|
|
414
414
|
5: ["secureStorage", "The native credential store is unavailable or locked. Unlock it and retry."],
|
|
@@ -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
|
-
|
|
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
|
package/build/auth/duo-mfa.js
CHANGED
|
@@ -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
|
-
|
|
74
|
-
|
|
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 =
|
|
130
|
-
if (!
|
|
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.`);
|
package/build/auth/purdue-sso.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
204
|
-
|
|
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.
|
|
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",
|