brightspace-mcp-server 3.12.3 → 3.13.0
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 +9 -0
- package/build/auth/auth-lock.js +6 -1
- package/build/auth/auth-runner.js +69 -11
- package/build/auth/browser-auth.js +19 -4
- package/build/auth/credential-store.js +22 -0
- package/build/auth/mfa-challenge.js +23 -1
- package/build/auth/purdue-sso.js +232 -13
- package/build/auth/sso-flow.js +18 -1
- package/build/auth/totp.js +83 -0
- package/build/auth-cli.js +15 -3
- package/build/setup.js +31 -0
- package/build/tools/tool-helpers.js +9 -0
- package/build/utils/config-store.js +2 -2
- package/build/utils/config.js +17 -0
- package/build/utils/dev-activity.js +4 -2
- package/build/utils/secure-config.js +19 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -97,6 +97,7 @@ All optional, set in your AI app's MCP `env` config or your shell. The full list
|
|
|
97
97
|
|---------|--------------|
|
|
98
98
|
| `D2L_REMEMBER_MFA=true` | Tick Microsoft's "Don't ask again" box so later sign-ins skip the second factor (off by default; not for shared computers). `setup` asks this and saves it as `rememberMfa` in `config.json`; the env var overrides it |
|
|
99
99
|
| `D2L_PASSWORDLESS=true` | Sign in with Microsoft Entra's passwordless phone approval, so no password is saved on the computer (off by default; Entra schools only). Every sign-in, including automatic ones, then waits for you to approve on your phone, so it suits interactive use, not unattended or scheduled jobs. `setup` asks this and saves it as `passwordless` in `config.json`; the env var overrides it ([details](docs/sign-in.md#passwordless-sign-in)) |
|
|
100
|
+
| `D2L_TOTP_SECRET` | An authenticator setup key, so a Microsoft Entra "enter a code" challenge is answered on this computer instead of on your phone (off by default). Prefer `setup`, which keeps the key in your OS keychain; this variable is for CI and containers with no keychain and is weaker, because anything that can read the process environment gets your second factor ([details](docs/sign-in.md#answering-code-challenges-without-your-phone)) |
|
|
100
101
|
| `D2L_HEADLESS=false` | Show the browser window during sign-in, for MFA methods that need a click |
|
|
101
102
|
| `D2L_DUO_PASSCODE=1` | On Duo, type a passcode instead of waiting for a push |
|
|
102
103
|
| `D2L_SESSION_COOKIE` / `D2L_ACCESS_TOKEN` | Skip the browser entirely with a pasted cookie or token ([how](docs/sign-in.md#no-browser-paste-a-session-cookie-or-token)) |
|
|
@@ -115,6 +116,14 @@ npx -y brightspace-mcp-server@latest auth
|
|
|
115
116
|
|
|
116
117
|
**On a Mac,** sign-in first reads your saved password from Keychain. If you picked a one-time **Allow** at the Keychain prompt, a later sign-in from your AI app or another terminal can fail or hang waiting for an approval nobody sees; pick **Always Allow** instead. That permission only lets the local Brightspace server read its own saved sign-in password, nothing else in your Keychain.
|
|
117
118
|
|
|
119
|
+
**If your school asks for a 6-digit code** rather than a phone approval, `setup` can save the
|
|
120
|
+
authenticator **setup key** (not a code) you got when you enrolled the app. From then on automatic
|
|
121
|
+
sign-ins generate the code on this computer and finish without you, which is what unattended and
|
|
122
|
+
scheduled use needs. It is optional and off unless you paste a key. The tradeoff is real: the key
|
|
123
|
+
sits beside your password, so on **this** computer your two factors become one. It still stops
|
|
124
|
+
anyone who only has your password, and it changes nothing for your other devices. Full enrollment
|
|
125
|
+
steps: [docs/sign-in.md](docs/sign-in.md#answering-code-challenges-without-your-phone).
|
|
126
|
+
|
|
118
127
|
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).
|
|
119
128
|
|
|
120
129
|
## Something not working?
|
package/build/auth/auth-lock.js
CHANGED
|
@@ -45,7 +45,12 @@ async function renameThroughContention(from, to) {
|
|
|
45
45
|
export const CHALLENGE_FILE = "challenge.json";
|
|
46
46
|
export class AuthenticationInProgressError extends Error {
|
|
47
47
|
code = "AUTH_IN_PROGRESS";
|
|
48
|
-
/**
|
|
48
|
+
/**
|
|
49
|
+
* The owner's MFA challenge, when the caller looked it up (see
|
|
50
|
+
* BrowserAuth.authenticate). Structurally `PendingChallenge` from
|
|
51
|
+
* mfa-challenge.ts, spelled out here because that module imports
|
|
52
|
+
* CHALLENGE_FILE from this one.
|
|
53
|
+
*/
|
|
49
54
|
challenge;
|
|
50
55
|
constructor() {
|
|
51
56
|
super("Authentication already in progress. Retry after the current authentication finishes.");
|
|
@@ -11,7 +11,7 @@ import { AuthError } from "../utils/errors.js";
|
|
|
11
11
|
import { AUTH_COMMAND } from "../utils/commands.js";
|
|
12
12
|
import { devActivity } from "../utils/dev-activity.js";
|
|
13
13
|
import { parsePhaseMarker } from "./auth-phases.js";
|
|
14
|
-
import { authLockPath, challengeRelayedAt } from "./mfa-challenge.js";
|
|
14
|
+
import { AUTOMATIC_PENDING_MARKER, authLockPath, challengeRelayedAt } from "./mfa-challenge.js";
|
|
15
15
|
/**
|
|
16
16
|
* Timeout for the auth process. It has to outlast the child's own MFA wait,
|
|
17
17
|
* which is five minutes: a person has to find their phone, unlock it, and read
|
|
@@ -89,6 +89,13 @@ function mfaPendingFailure(numberMatch) {
|
|
|
89
89
|
? ["mfaPending", `Open Microsoft Authenticator and enter ${numberMatch} within 5 minutes, then try again.`]
|
|
90
90
|
: ["mfaPending", "An MFA approval was not completed in time. Try again."];
|
|
91
91
|
}
|
|
92
|
+
/**
|
|
93
|
+
* The child is answering its own verification code from the saved
|
|
94
|
+
* authenticator enrollment. Deliberately NOT an mfaPending: there is nothing
|
|
95
|
+
* on anyone's phone to approve, so a caller told "approve the request" would
|
|
96
|
+
* send the user looking for a prompt that will never arrive.
|
|
97
|
+
*/
|
|
98
|
+
const automaticPendingFailure = () => new AuthProcessError("automaticPending", "Sign-in is entering its own verification code in the background. Try again.");
|
|
92
99
|
export class AuthProcessError extends AuthError {
|
|
93
100
|
kind;
|
|
94
101
|
numberMatch;
|
|
@@ -174,9 +181,13 @@ function forwardLines(stream, emit) {
|
|
|
174
181
|
});
|
|
175
182
|
}
|
|
176
183
|
function dedupeChallenge(error, wave) {
|
|
177
|
-
|
|
184
|
+
// automaticPending repeats across a batch exactly as mfaPending did (#212),
|
|
185
|
+
// just without digits: one sign-in, N copies of the same paragraph.
|
|
186
|
+
if (!(error instanceof AuthProcessError) || error.duplicate)
|
|
178
187
|
return error;
|
|
179
|
-
|
|
188
|
+
if (error.kind !== "mfaPending" && error.kind !== "automaticPending")
|
|
189
|
+
return error;
|
|
190
|
+
const key = `${error.kind}:${error.numberMatch ?? ""}`;
|
|
180
191
|
if (!wave.delivered.has(key)) {
|
|
181
192
|
wave.delivered.add(key);
|
|
182
193
|
// Once a challenge has gone back to the user, the batch is over: every
|
|
@@ -283,15 +294,23 @@ export class AuthRunner {
|
|
|
283
294
|
}
|
|
284
295
|
catch (error) {
|
|
285
296
|
const childDone = this.childDone;
|
|
286
|
-
if (!
|
|
297
|
+
if (!childDone || !(error instanceof AuthProcessError))
|
|
298
|
+
throw error;
|
|
299
|
+
// An mfaPending is only worth waiting on when the caller can relay the
|
|
300
|
+
// number mid-call. An automaticPending needs no relay at all — nobody is
|
|
301
|
+
// being asked for anything — so keep waiting on it either way.
|
|
302
|
+
const joinable = error.kind === "automaticPending" || (Boolean(onChallenge) && error.kind === "mfaPending");
|
|
303
|
+
if (!joinable)
|
|
287
304
|
throw error;
|
|
288
305
|
const windowMs = pollWindowMs(Date.now() - startedAt);
|
|
289
306
|
if (windowMs <= 0)
|
|
290
307
|
throw error;
|
|
291
|
-
|
|
292
|
-
|
|
308
|
+
if (error.kind === "mfaPending") {
|
|
309
|
+
try {
|
|
310
|
+
onChallenge?.(error.numberMatch);
|
|
311
|
+
}
|
|
312
|
+
catch { /* Announcing must not interrupt authentication. */ }
|
|
293
313
|
}
|
|
294
|
-
catch { /* Announcing must not interrupt authentication. */ }
|
|
295
314
|
return this.awaitBackgroundChild(childDone, windowMs);
|
|
296
315
|
}
|
|
297
316
|
}
|
|
@@ -354,6 +373,11 @@ export class AuthRunner {
|
|
|
354
373
|
if (settled)
|
|
355
374
|
return;
|
|
356
375
|
settled = true;
|
|
376
|
+
const pending = this.pendingChallenge ?? challenge;
|
|
377
|
+
if (pending?.kind === "automaticPending") {
|
|
378
|
+
reject(automaticPendingFailure());
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
357
381
|
const numberMatch = this.pendingChallenge?.numberMatch ?? challenge?.numberMatch;
|
|
358
382
|
reject(new AuthProcessError(...mfaPendingFailure(numberMatch), numberMatch));
|
|
359
383
|
}, graceMs);
|
|
@@ -405,6 +429,10 @@ export class AuthRunner {
|
|
|
405
429
|
let childFinished = false;
|
|
406
430
|
let numberMatch;
|
|
407
431
|
let challengeSeen = false;
|
|
432
|
+
/** The child reported it is answering its own code (see the marker below). */
|
|
433
|
+
let automaticSeen = false;
|
|
434
|
+
/** watchAttention is armed once per child; a second timer could kill a live sign-in. */
|
|
435
|
+
let attentionWatched = false;
|
|
408
436
|
let abandonTimer;
|
|
409
437
|
let killTimer;
|
|
410
438
|
const kill = (signal) => {
|
|
@@ -493,15 +521,23 @@ export class AuthRunner {
|
|
|
493
521
|
kill("SIGKILL");
|
|
494
522
|
finishChild(new AuthProcessError("timeout", `The sign-in was stopped because nobody approved its MFA prompt. Try again, or run ${AUTH_COMMAND}.`));
|
|
495
523
|
};
|
|
524
|
+
const startWatching = () => {
|
|
525
|
+
if (attentionWatched)
|
|
526
|
+
return;
|
|
527
|
+
attentionWatched = true;
|
|
528
|
+
watchAttention();
|
|
529
|
+
};
|
|
496
530
|
const publishChallenge = (matched) => {
|
|
497
531
|
challengeSeen = true;
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
532
|
+
// An automatic sign-in that later shows a real approval challenge (it
|
|
533
|
+
// fell back, or Entra changed its mind) must be upgraded, so a joiner
|
|
534
|
+
// is told the number instead of "still working".
|
|
535
|
+
const firstChallenge = this.pendingChallenge === null || this.pendingChallenge.kind === "automaticPending";
|
|
536
|
+
startWatching();
|
|
501
537
|
if (firstChallenge)
|
|
502
538
|
devActivity("mfa_observed", { elapsedMs: Date.now() - started });
|
|
503
539
|
if (firstChallenge || matched) {
|
|
504
|
-
this.pendingChallenge = { numberMatch: matched ?? this.pendingChallenge?.numberMatch };
|
|
540
|
+
this.pendingChallenge = { kind: "mfaPending", numberMatch: matched ?? this.pendingChallenge?.numberMatch };
|
|
505
541
|
}
|
|
506
542
|
if (firstChallenge)
|
|
507
543
|
resolveChallengeSignal();
|
|
@@ -509,6 +545,21 @@ export class AuthRunner {
|
|
|
509
545
|
settleCaller(new AuthProcessError(...mfaPendingFailure(this.pendingChallenge?.numberMatch), this.pendingChallenge?.numberMatch));
|
|
510
546
|
}
|
|
511
547
|
};
|
|
548
|
+
/**
|
|
549
|
+
* The child is typing its own verification code. Watched for abandonment
|
|
550
|
+
* exactly like an approval challenge: a sign-in nobody is waiting on
|
|
551
|
+
* still holds the cross-process lock, and every caller is told to retry
|
|
552
|
+
* right away, which keeps it alive (see ABANDON_MS).
|
|
553
|
+
*/
|
|
554
|
+
const publishAutomaticProgress = () => {
|
|
555
|
+
automaticSeen = true;
|
|
556
|
+
if (this.pendingChallenge)
|
|
557
|
+
return;
|
|
558
|
+
this.pendingChallenge = { kind: "automaticPending" };
|
|
559
|
+
startWatching();
|
|
560
|
+
resolveChallengeSignal();
|
|
561
|
+
settleCaller(automaticPendingFailure());
|
|
562
|
+
};
|
|
512
563
|
// Runs once the child is actually done. Settles the caller too, if an
|
|
513
564
|
// early answer had not already done so; otherwise this is just the
|
|
514
565
|
// background sign-in finishing, which only childDone's joiner sees.
|
|
@@ -567,6 +618,9 @@ export class AuthRunner {
|
|
|
567
618
|
else if (MFA_PENDING_MARKER.test(line)) {
|
|
568
619
|
publishChallenge(undefined);
|
|
569
620
|
}
|
|
621
|
+
else if (line === AUTOMATIC_PENDING_MARKER) {
|
|
622
|
+
publishAutomaticProgress();
|
|
623
|
+
}
|
|
570
624
|
else {
|
|
571
625
|
log("DEBUG", line);
|
|
572
626
|
}
|
|
@@ -602,6 +656,10 @@ export class AuthRunner {
|
|
|
602
656
|
// was told what to approve, so answer with that, not "busy".
|
|
603
657
|
if (code === 2 && challengeSeen)
|
|
604
658
|
failures[2] = mfaPendingFailure(numberMatch);
|
|
659
|
+
// Busy because another process is signing in automatically: there is
|
|
660
|
+
// nothing to approve, so say that rather than "busy".
|
|
661
|
+
else if (code === 2 && automaticSeen)
|
|
662
|
+
failures[2] = ["automaticPending", automaticPendingFailure().message];
|
|
605
663
|
const [kind, message] = failures[code ?? -1] ?? ["failed", `Authentication failed. Run ${AUTH_COMMAND} to try again.`];
|
|
606
664
|
kill("SIGKILL");
|
|
607
665
|
finishChild(new AuthProcessError(kind, message, kind === "mfaPending" ? numberMatch : undefined));
|
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
import { readFileSync, accessSync } from "node:fs";
|
|
7
7
|
import { BrowserAuthError } from "../utils/errors.js";
|
|
8
8
|
import { log } from "../utils/logger.js";
|
|
9
|
-
import { createSSOFlow, UnsupportedAuthenticationError, MfaApprovalError } from "./sso-flow.js";
|
|
9
|
+
import { createSSOFlow, UnsupportedAuthenticationError, MfaApprovalError, AutomaticCodeAuthenticationError } from "./sso-flow.js";
|
|
10
10
|
import { isDuoPrompt } from "./duo-mfa.js";
|
|
11
11
|
import { BrowserStateStore } from "./browser-state-store.js";
|
|
12
12
|
import { hasNewerEntraState, recordMicrosoftSession } from "./microsoft-session.js";
|
|
13
13
|
import { acquireProcessLock, AuthenticationInProgressError } from "./auth-lock.js";
|
|
14
|
-
import { authLockPath, publishChallenge, relayChallenge } from "./mfa-challenge.js";
|
|
14
|
+
import { authLockPath, publishAutomaticProgress, publishChallenge, relayChallenge } from "./mfa-challenge.js";
|
|
15
15
|
import { AuthCooldown } from "./auth-cooldown.js";
|
|
16
16
|
import { mintAccessToken } from "./token-mint.js";
|
|
17
17
|
import { isMissingBrowserError, PLAYWRIGHT_INSTALL_HINT } from "../utils/browser-install.js";
|
|
@@ -65,13 +65,25 @@ export class BrowserAuth {
|
|
|
65
65
|
this.onPhase = options.onPhase;
|
|
66
66
|
const { onMfaChallenge } = options;
|
|
67
67
|
this.lockPath = authLockPath(config.sessionDir);
|
|
68
|
+
const { onAutomaticPending } = options;
|
|
68
69
|
this.ssoFlow = createSSOFlow(config, options.requestMfaCode, onMfaChallenge && ((number) => {
|
|
69
70
|
this.challengeSeenAt ??= Date.now();
|
|
70
71
|
// Other server processes sharing this session cannot see our caller's
|
|
71
72
|
// tool response; the lock directory is where they look instead.
|
|
72
73
|
this.publishing = this.publishing.then(() => publishChallenge(this.lockPath, number)).catch(() => { });
|
|
73
74
|
onMfaChallenge(number);
|
|
74
|
-
}))
|
|
75
|
+
}), () => {
|
|
76
|
+
// Published through the same lock-directory file as a number-match
|
|
77
|
+
// challenge (issue #199), so a contender in another process relays
|
|
78
|
+
// "automatic sign-in in progress" rather than an approval nobody asked
|
|
79
|
+
// for — and the relay keeps this sign-in from being abandoned.
|
|
80
|
+
this.challengeSeenAt ??= Date.now();
|
|
81
|
+
this.publishing = this.publishing.then(() => publishAutomaticProgress(this.lockPath)).catch(() => { });
|
|
82
|
+
try {
|
|
83
|
+
onAutomaticPending?.();
|
|
84
|
+
}
|
|
85
|
+
catch { /* Reporting must not interrupt authentication. */ }
|
|
86
|
+
});
|
|
75
87
|
this.stateStore = new BrowserStateStore(config.sessionDir);
|
|
76
88
|
this.cooldown = new AuthCooldown(config.sessionDir);
|
|
77
89
|
}
|
|
@@ -422,8 +434,11 @@ export class BrowserAuth {
|
|
|
422
434
|
}
|
|
423
435
|
}
|
|
424
436
|
catch (error) {
|
|
425
|
-
|
|
437
|
+
// An automatic code sign-in that failed gets the same cooldown: retrying
|
|
438
|
+
// it in a tight loop would walk Entra's attempt limits down for nothing.
|
|
439
|
+
if (error instanceof MfaApprovalError || error instanceof AutomaticCodeAuthenticationError) {
|
|
426
440
|
await this.cooldown.recordMfaFailure();
|
|
441
|
+
}
|
|
427
442
|
throw error;
|
|
428
443
|
}
|
|
429
444
|
finally {
|
|
@@ -162,6 +162,28 @@ function passwordAccount(baseUrl, username) {
|
|
|
162
162
|
const identity = JSON.stringify([new URL(baseUrl).origin, username]);
|
|
163
163
|
return `password:${createHash("sha256").update(identity).digest("hex")}`;
|
|
164
164
|
}
|
|
165
|
+
/**
|
|
166
|
+
* The authenticator enrollment's own account, derived exactly like
|
|
167
|
+
* passwordAccount (same origin + username, same casing rules) so one account's
|
|
168
|
+
* two secrets live or die together, under a separate `totp:` prefix so the
|
|
169
|
+
* seed can never be returned where a password is expected.
|
|
170
|
+
* Idea from ElliotDrel/brightspace-mcp-server (branch codex/purdue-totp).
|
|
171
|
+
*/
|
|
172
|
+
function totpAccount(baseUrl, username) {
|
|
173
|
+
const identity = JSON.stringify([new URL(baseUrl).origin, username]);
|
|
174
|
+
return `totp:${createHash("sha256").update(identity).digest("hex")}`;
|
|
175
|
+
}
|
|
176
|
+
/** The account's saved authenticator enrollment, or null when none was saved. */
|
|
177
|
+
export async function getStoredTotpUri(baseUrl, username, backend = nativeCredentialBackend) {
|
|
178
|
+
return backend.getPassword(SERVICE, totpAccount(baseUrl, username));
|
|
179
|
+
}
|
|
180
|
+
export async function setStoredTotpUri(baseUrl, username, uri, backend = nativeCredentialBackend) {
|
|
181
|
+
const account = totpAccount(baseUrl, username);
|
|
182
|
+
await backend.setPassword(SERVICE, account, uri);
|
|
183
|
+
if (await backend.getPassword(SERVICE, account) !== uri) {
|
|
184
|
+
throw new NativeCredentialStoreError("The authenticator enrollment could not be verified in the native credential store. Existing configuration was preserved.");
|
|
185
|
+
}
|
|
186
|
+
}
|
|
165
187
|
export async function getStoredPassword(baseUrl, username, backend = nativeCredentialBackend) {
|
|
166
188
|
return backend.getPassword(SERVICE, passwordAccount(baseUrl, username));
|
|
167
189
|
}
|
|
@@ -26,6 +26,17 @@ export async function publishChallenge(lockPath, numberMatch) {
|
|
|
26
26
|
const challenge = numberMatch && NUMBER_MATCH.test(numberMatch) ? { numberMatch } : {};
|
|
27
27
|
await fs.writeFile(path.join(lockPath, CHALLENGE_FILE), JSON.stringify(challenge), { mode: 0o600 });
|
|
28
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* Record that this process's sign-in is answering its own verification code,
|
|
31
|
+
* so a contender sharing the session can say so instead of announcing an
|
|
32
|
+
* approval that was never requested. Written through the same file as a
|
|
33
|
+
* number-match challenge, so it disappears with the lock and a contender
|
|
34
|
+
* reading it keeps the sign-in from being abandoned.
|
|
35
|
+
*/
|
|
36
|
+
export async function publishAutomaticProgress(lockPath) {
|
|
37
|
+
const challenge = { kind: "automatic" };
|
|
38
|
+
await fs.writeFile(path.join(lockPath, CHALLENGE_FILE), JSON.stringify(challenge), { mode: 0o600 });
|
|
39
|
+
}
|
|
29
40
|
/**
|
|
30
41
|
* The challenge the lock owner is showing, or undefined when it has published
|
|
31
42
|
* none. Marks the challenge as just relayed, so the owner keeps waiting.
|
|
@@ -44,7 +55,12 @@ export async function relayChallenge(lockPath) {
|
|
|
44
55
|
const now = new Date();
|
|
45
56
|
await fs.utimes(file, now, now).catch(() => { });
|
|
46
57
|
const numberMatch = challenge.numberMatch;
|
|
47
|
-
|
|
58
|
+
const relayed = {};
|
|
59
|
+
if (challenge.kind === "automatic")
|
|
60
|
+
relayed.kind = "automatic";
|
|
61
|
+
if (typeof numberMatch === "string" && NUMBER_MATCH.test(numberMatch))
|
|
62
|
+
relayed.numberMatch = numberMatch;
|
|
63
|
+
return relayed;
|
|
48
64
|
}
|
|
49
65
|
/** When the lock's challenge was last published or relayed, or undefined when there is none. */
|
|
50
66
|
export function challengeRelayedAt(lockPath) {
|
|
@@ -55,6 +71,12 @@ export function challengeRelayedAt(lockPath) {
|
|
|
55
71
|
return undefined;
|
|
56
72
|
}
|
|
57
73
|
}
|
|
74
|
+
/**
|
|
75
|
+
* The stdout marker for a sign-in that is answering its own verification
|
|
76
|
+
* code: no digits to carry, and nothing for the user to do but wait. Kept
|
|
77
|
+
* beside challengeMarker so the child and AuthRunner cannot drift apart.
|
|
78
|
+
*/
|
|
79
|
+
export const AUTOMATIC_PENDING_MARKER = "AUTH_AUTOMATIC_PENDING";
|
|
58
80
|
/** The stdout marker AuthRunner parses for a challenge. */
|
|
59
81
|
export function challengeMarker(numberMatch) {
|
|
60
82
|
return numberMatch ? `MFA_NUMBER:${numberMatch}` : "MFA_PENDING";
|
package/build/auth/purdue-sso.js
CHANGED
|
@@ -5,9 +5,11 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { BrowserAuthError } from "../utils/errors.js";
|
|
7
7
|
import { log } from "../utils/logger.js";
|
|
8
|
-
import {
|
|
8
|
+
import { devActivity } from "../utils/dev-activity.js";
|
|
9
|
+
import { AutomaticCodeAuthenticationError, MfaApprovalError, UnsupportedAuthenticationError } from "./sso-flow.js";
|
|
9
10
|
import { DuoMfaHandler } from "./duo-mfa.js";
|
|
10
11
|
import { AUTH_COMMAND } from "../utils/commands.js";
|
|
12
|
+
import { generateTotp, secondsUntilFreshCode } from "./totp.js";
|
|
11
13
|
// Entra names its username field type=email/loginfmt; Shibboleth portals (USC's
|
|
12
14
|
// login.usc.edu among them) use the protocol's j_username.
|
|
13
15
|
const EMAIL_SELECTORS = ["input[type=email]", "input[name=loginfmt]", "input[name=j_username]", "input#signinid"];
|
|
@@ -27,10 +29,13 @@ const NUMBER_MATCH_SELECTOR = "#idRichContext_DisplaySign";
|
|
|
27
29
|
* which the tenant shows instead of a password page once the account has
|
|
28
30
|
* registered passwordless sign-in in Authenticator. The "Use your password
|
|
29
31
|
* instead" link is the marker browser-auth.ts already relies on; the
|
|
30
|
-
* RemoteNGC ids
|
|
32
|
+
* RemoteNGC ids were verified live on Purdue's tenant on 2026-10-09 (#idRemoteNGC_DisplaySign,
|
|
33
|
+
* #idDiv_RemoteNGC_PollingDescription, #idA_PWD_SwitchToPassword).
|
|
31
34
|
* The number shown there is typed into Authenticator like number match.
|
|
32
35
|
*/
|
|
33
36
|
const PASSWORDLESS_NUMBER_SELECTOR = "#idRemoteNGC_DisplaySign";
|
|
37
|
+
/** The approval view's "Use your password instead" link (live-verified on Purdue, 2026-10-09). */
|
|
38
|
+
const PASSWORDLESS_SWITCH_SELECTOR = "#idA_PWD_SwitchToPassword";
|
|
34
39
|
const PASSWORDLESS_APPROVAL_SELECTORS = [
|
|
35
40
|
PASSWORDLESS_NUMBER_SELECTOR,
|
|
36
41
|
"#idDiv_RemoteNGC_PollingDescription",
|
|
@@ -104,6 +109,41 @@ const RESEND_NOT_FOUND_WARN_MS = 30_000;
|
|
|
104
109
|
* elapsed time creep past a single interval and defeat the guard.
|
|
105
110
|
*/
|
|
106
111
|
const RESEND_CLICK_GUARD_MS = NUMBER_MATCH_POLL_MS * 2;
|
|
112
|
+
/**
|
|
113
|
+
* Entra's own labels for reaching the verification-code form. Clicked only
|
|
114
|
+
* when an authenticator enrollment is saved, and each at most once per login,
|
|
115
|
+
* so this can never loop between method pages. The exact-match code label is
|
|
116
|
+
* tried first; the vaguer "another way" links only open the method list.
|
|
117
|
+
* Ported from ElliotDrel/brightspace-mcp-server (branch codex/purdue-totp).
|
|
118
|
+
*/
|
|
119
|
+
const CODE_METHOD_STEPS = [
|
|
120
|
+
["code", /^use a verification code$/i],
|
|
121
|
+
["other", /^(?:I can.t use my .+ right now|sign in another way|use a different verification option)$/i],
|
|
122
|
+
];
|
|
123
|
+
/** Let Entra re-render the method page before the loop looks at it again. */
|
|
124
|
+
const METHOD_SWITCH_SETTLE_MS = 500;
|
|
125
|
+
/**
|
|
126
|
+
* Never submit a code with less life than this left: Entra validates a moment
|
|
127
|
+
* after the click, and a code that rolls over in between is rejected — which
|
|
128
|
+
* would spend one of the three attempts for nothing.
|
|
129
|
+
*/
|
|
130
|
+
const MIN_CODE_LIFETIME_S = 5;
|
|
131
|
+
/**
|
|
132
|
+
* How long an automatic code sign-in runs before it tells the caller it is
|
|
133
|
+
* working. Long enough that a quick sign-in never reports anything, short
|
|
134
|
+
* enough to answer inside a client's request timeout.
|
|
135
|
+
*/
|
|
136
|
+
const AUTOMATIC_PENDING_NOTICE_MS = 15_000;
|
|
137
|
+
/**
|
|
138
|
+
* How long Entra gets to offer a way to switch to a verification code before
|
|
139
|
+
* this flow gives up on typing one and waits for a phone approval like any
|
|
140
|
+
* other login. A tenant can show a challenge with no code method at all; the
|
|
141
|
+
* alternative to this fallback is polling a page nothing will ever click for
|
|
142
|
+
* the full five minutes.
|
|
143
|
+
*/
|
|
144
|
+
const AUTOMATIC_FALLBACK_MS = 30_000;
|
|
145
|
+
/** Where Entra prints the account it believes is signing in. */
|
|
146
|
+
const ACCOUNT_LABEL_SELECTORS = ["#displayName", "#signInName", "#userDisplayName"];
|
|
107
147
|
/** Microsoft expects Purdue's full sign-in name, while setup also accepts a career account. */
|
|
108
148
|
function signInName(username, baseUrl) {
|
|
109
149
|
const isPurdue = baseUrl && new URL(baseUrl).hostname.toLowerCase() === "purdue.brightspace.com";
|
|
@@ -112,6 +152,8 @@ function signInName(username, baseUrl) {
|
|
|
112
152
|
export class PurdueSSOFlow {
|
|
113
153
|
config;
|
|
114
154
|
accountHintSubmitted = false;
|
|
155
|
+
/** Method-switch controls already clicked this login; each is clicked at most once. */
|
|
156
|
+
methodClicked = new Set();
|
|
115
157
|
/** Authenticator codes asked for during this login. See submitMfaCode. */
|
|
116
158
|
mfaCodeAttempts = 0;
|
|
117
159
|
/**
|
|
@@ -344,8 +386,29 @@ export class PurdueSSOFlow {
|
|
|
344
386
|
if (!this.config.baseUrl) {
|
|
345
387
|
throw new UnsupportedAuthenticationError("A school URL is required to verify authentication.");
|
|
346
388
|
}
|
|
347
|
-
const
|
|
389
|
+
const startedAt = Date.now();
|
|
390
|
+
const deadline = startedAt + MFA_TIMEOUT_MS;
|
|
348
391
|
let challenged = false;
|
|
392
|
+
/**
|
|
393
|
+
* True once this login has told the user to go approve something. Only
|
|
394
|
+
* then is a timeout really a missed approval; an automatic code sign-in
|
|
395
|
+
* that stalls must not be reported as one.
|
|
396
|
+
*/
|
|
397
|
+
let manualChallenged = false;
|
|
398
|
+
/** True once automatic code entry applied to a poll of this login. */
|
|
399
|
+
let automaticEngaged = false;
|
|
400
|
+
/** True once onAutomaticPending has reported this login. */
|
|
401
|
+
let automaticAnnounced = false;
|
|
402
|
+
/**
|
|
403
|
+
* True once Entra has had AUTOMATIC_FALLBACK_MS to offer a way to switch
|
|
404
|
+
* to a verification code and offered none. One-way: from then on this
|
|
405
|
+
* login announces and waits exactly as it would with no enrollment saved.
|
|
406
|
+
*/
|
|
407
|
+
let methodSwitchExhausted = false;
|
|
408
|
+
/** "Use your password instead" clicks this login; capped so a page that keeps offering it cannot loop. */
|
|
409
|
+
let passwordSwitches = 0;
|
|
410
|
+
/** True once Duo answered a challenge: Duo's codes are not Entra's. */
|
|
411
|
+
let duoChallengeObserved = false;
|
|
349
412
|
let announced = null;
|
|
350
413
|
/** True once onMfaChallenge has been told about this login, number or not. */
|
|
351
414
|
let announcedToCaller = false;
|
|
@@ -371,10 +434,46 @@ export class PurdueSSOFlow {
|
|
|
371
434
|
log("INFO", "Login successful - verified Brightspace home");
|
|
372
435
|
return;
|
|
373
436
|
}
|
|
374
|
-
if (await this.duoMfa.handle(page))
|
|
437
|
+
if (await this.duoMfa.handle(page)) {
|
|
375
438
|
challenged = true;
|
|
376
|
-
|
|
439
|
+
manualChallenged = true;
|
|
440
|
+
duoChallengeObserved = true;
|
|
441
|
+
}
|
|
442
|
+
// Passwordless phone sign-in in place of the password page. Once an
|
|
443
|
+
// account turns on phone sign-in in Authenticator, Entra answers the
|
|
444
|
+
// username with its approval view, where automaticCodeApplicable rightly
|
|
445
|
+
// refuses to type a code: the phone is the FIRST factor there. Only
|
|
446
|
+
// after the password does Entra ask for a second factor a code can
|
|
447
|
+
// answer. So with an enrollment AND a password saved, and the
|
|
448
|
+
// passwordless option off, take "Use your password instead". Without an
|
|
449
|
+
// enrollment the approval view is left to the phone exactly as before.
|
|
450
|
+
// Seen live on Purdue's tenant on 2026-10-09 (iclicker-automation#6).
|
|
451
|
+
if (this.config.totpUri && this.config.password && !this.config.passwordless &&
|
|
452
|
+
passwordSwitches < 2 && await this.anyVisible(page, [PASSWORDLESS_SWITCH_SELECTOR])) {
|
|
453
|
+
passwordSwitches += 1;
|
|
454
|
+
log("INFO", "Microsoft offered passwordless phone sign-in; using the saved password so the authenticator code can answer MFA.");
|
|
455
|
+
await this.clickWhenReady(page, [PASSWORDLESS_SWITCH_SELECTOR]);
|
|
456
|
+
if (await this.fillWhenReady(page, PASSWORD_SELECTORS, this.config.password)) {
|
|
457
|
+
await this.clickWhenReady(page, SUBMIT_SELECTORS);
|
|
458
|
+
}
|
|
459
|
+
await page.waitForTimeout(METHOD_SWITCH_SETTLE_MS);
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
// Whether this poll may answer the challenge itself. Recomputed every
|
|
463
|
+
// poll, because the page — and so the identity provider and method on
|
|
464
|
+
// screen — can change under us.
|
|
465
|
+
const automatic = await this.automaticCodeApplicable(page, duoChallengeObserved, methodSwitchExhausted);
|
|
466
|
+
if (automatic)
|
|
467
|
+
automaticEngaged = true;
|
|
468
|
+
if (await this.submitMfaCode(page, automatic))
|
|
377
469
|
challenged = true;
|
|
470
|
+
const switched = automatic ? await this.selectCodeMethod(page) : "skipped";
|
|
471
|
+
if (switched === "clicked") {
|
|
472
|
+
await page.waitForTimeout(METHOD_SWITCH_SETTLE_MS);
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
/** False while this poll is answering the challenge on the user's behalf. */
|
|
476
|
+
const manualRequired = !automatic;
|
|
378
477
|
const number = await this.readNumberMatch(page);
|
|
379
478
|
if (number)
|
|
380
479
|
sawNumber = true;
|
|
@@ -382,6 +481,13 @@ export class PurdueSSOFlow {
|
|
|
382
481
|
await page.locator("#idDiv_SAOTCAS_Title").first().isVisible().catch(() => false) ||
|
|
383
482
|
await page.locator("#idDiv_SAOTCC_Title").first().isVisible().catch(() => false) ||
|
|
384
483
|
await this.anyVisible(page, PASSWORDLESS_APPROVAL_SELECTORS);
|
|
484
|
+
// Entra showed a challenge but never a way to type a code. Stop
|
|
485
|
+
// waiting for one and fall back to the announce-and-approve path this
|
|
486
|
+
// flow has always used, rather than polling a dead page for 5 minutes.
|
|
487
|
+
if (switched === "noSwitch" && challengeVisible && Date.now() - startedAt >= AUTOMATIC_FALLBACK_MS) {
|
|
488
|
+
methodSwitchExhausted = true;
|
|
489
|
+
log("WARN", "Microsoft offered no way to enter a verification code; waiting for approval on your device instead.");
|
|
490
|
+
}
|
|
385
491
|
// With no password saved, a password page can only end in a timeout.
|
|
386
492
|
if (this.config.passwordless && !this.config.password && !challengeVisible && await this.isPasswordOnlyPage(page))
|
|
387
493
|
this.savedPassword();
|
|
@@ -389,14 +495,15 @@ export class PurdueSSOFlow {
|
|
|
389
495
|
// the existing poll without delaying the first challenge announcement.
|
|
390
496
|
if (challengeVisible)
|
|
391
497
|
await this.rememberMfaDevice(page);
|
|
392
|
-
if (challengeVisible && !challenged) {
|
|
498
|
+
if (challengeVisible && !challenged && manualRequired) {
|
|
393
499
|
challenged = true;
|
|
500
|
+
manualChallenged = true;
|
|
394
501
|
log("WARN", "Waiting up to 5 minutes for Microsoft MFA approval on your device.");
|
|
395
502
|
this.config.onMfaChallenge?.(number);
|
|
396
503
|
announcedToCaller = true;
|
|
397
504
|
lastAnnouncedNumber = number;
|
|
398
505
|
}
|
|
399
|
-
if (number && number !== announced) {
|
|
506
|
+
if (number && number !== announced && manualRequired) {
|
|
400
507
|
announced = number;
|
|
401
508
|
log("WARN", `Number match: ${number}. Enter it in Microsoft Authenticator.`);
|
|
402
509
|
if (!announcedToCaller || number !== lastAnnouncedNumber) {
|
|
@@ -415,7 +522,7 @@ export class PurdueSSOFlow {
|
|
|
415
522
|
// request until MFA_TIMEOUT_MS; readNumberMatch on the next poll
|
|
416
523
|
// picks up the fresh number, and the re-announce logic above tells
|
|
417
524
|
// the caller about it.
|
|
418
|
-
if (sawNumber && number === null && new URL(page.url()).hostname === "login.microsoftonline.com") {
|
|
525
|
+
if (manualRequired && sawNumber && number === null && new URL(page.url()).hostname === "login.microsoftonline.com") {
|
|
419
526
|
if (numberVanishedAt === null)
|
|
420
527
|
numberVanishedAt = Date.now();
|
|
421
528
|
resendNotFoundWarned = await this.tryResendNumberMatch(page, {
|
|
@@ -429,6 +536,10 @@ export class PurdueSSOFlow {
|
|
|
429
536
|
},
|
|
430
537
|
});
|
|
431
538
|
}
|
|
539
|
+
if (automatic && !automaticAnnounced && Date.now() - startedAt >= AUTOMATIC_PENDING_NOTICE_MS) {
|
|
540
|
+
automaticAnnounced = true;
|
|
541
|
+
this.config.onAutomaticPending?.();
|
|
542
|
+
}
|
|
432
543
|
await this.clickProvenKmsi(page);
|
|
433
544
|
// The federated-domain trust prompt arrives after the IdP succeeds, so
|
|
434
545
|
// it has to be caught by this loop rather than by enterCredentials.
|
|
@@ -439,10 +550,19 @@ export class PurdueSSOFlow {
|
|
|
439
550
|
catch (error) {
|
|
440
551
|
if (error instanceof BrowserAuthError)
|
|
441
552
|
throw error;
|
|
553
|
+
// An automatic sign-in that never asked the user for anything is not a
|
|
554
|
+
// missed approval, so it must not be reported as one — unless this
|
|
555
|
+
// login fell back and really did ask (manualChallenged).
|
|
556
|
+
if (automaticEngaged && !manualChallenged) {
|
|
557
|
+
throw new AutomaticCodeAuthenticationError("Automatic code sign-in stopped before Brightspace was verified.", error);
|
|
558
|
+
}
|
|
442
559
|
if (challenged)
|
|
443
560
|
throw new MfaApprovalError(error, announced ?? undefined);
|
|
444
561
|
throw new UnsupportedAuthenticationError("Automatic sign-in stopped before a supported MFA challenge completed.", error);
|
|
445
562
|
}
|
|
563
|
+
if (automaticEngaged && !manualChallenged) {
|
|
564
|
+
throw new AutomaticCodeAuthenticationError("Automatic code sign-in did not reach a verified Brightspace session within 5 minutes.");
|
|
565
|
+
}
|
|
446
566
|
if (challenged)
|
|
447
567
|
throw new MfaApprovalError(undefined, announced ?? undefined);
|
|
448
568
|
throw new UnsupportedAuthenticationError("Sign-in did not reach a supported MFA challenge or Brightspace within 5 minutes.");
|
|
@@ -494,11 +614,19 @@ export class PurdueSSOFlow {
|
|
|
494
614
|
}
|
|
495
615
|
return null;
|
|
496
616
|
}
|
|
497
|
-
|
|
617
|
+
/**
|
|
618
|
+
* Submit a verification code, from the saved enrollment when `automatic` is
|
|
619
|
+
* set and otherwise from the user at the terminal. `automatic` is decided
|
|
620
|
+
* per poll by automaticCodeApplicable; with no enrollment saved it is always
|
|
621
|
+
* false and every line below behaves as it did before.
|
|
622
|
+
*/
|
|
623
|
+
async submitMfaCode(page, automatic = false) {
|
|
498
624
|
const input = await this.firstVisible(page, MFA_CODE_SELECTORS);
|
|
499
625
|
if (!input)
|
|
500
626
|
return false;
|
|
501
|
-
|
|
627
|
+
// A visible browser leaves code entry to the user — unless the code can be
|
|
628
|
+
// generated here, in which case there is nothing for them to type.
|
|
629
|
+
if (this.config.headless === false && !automatic)
|
|
502
630
|
return false;
|
|
503
631
|
// Ask again only after Entra's rejection message. This runs on every
|
|
504
632
|
// two-second poll, and Microsoft commonly leaves the field on screen while
|
|
@@ -523,15 +651,32 @@ export class PurdueSSOFlow {
|
|
|
523
651
|
if (this.mfaCodeAttempts >= MAX_MFA_CODE_ATTEMPTS) {
|
|
524
652
|
throw new BrowserAuthError(`Microsoft rejected ${this.mfaCodeAttempts} authenticator codes. Run \`${AUTH_COMMAND}\` to try again.`, "mfa_code");
|
|
525
653
|
}
|
|
526
|
-
log("WARN",
|
|
654
|
+
log("WARN", automatic
|
|
655
|
+
? "Microsoft rejected the code; waiting for a fresh one before retrying."
|
|
656
|
+
: "Microsoft rejected that code. Enter the current one from your authenticator app.");
|
|
527
657
|
}
|
|
528
|
-
if (!this.config.requestMfaCode) {
|
|
658
|
+
if (!automatic && !this.config.requestMfaCode) {
|
|
529
659
|
throw new UnsupportedAuthenticationError(`This MFA method requires a code. Run \`${AUTH_COMMAND}\` in a terminal to enter it.`);
|
|
530
660
|
}
|
|
531
661
|
await this.rememberMfaDevice(page);
|
|
532
662
|
this.mfaCodeAttempts += 1;
|
|
533
663
|
this.awaitingMfaCodeSettle = isRetry;
|
|
534
|
-
|
|
664
|
+
let code;
|
|
665
|
+
if (automatic) {
|
|
666
|
+
// Refuse to type a code to a page that is not showing this account.
|
|
667
|
+
await this.assertExpectedMicrosoftAccount(page);
|
|
668
|
+
// Resubmitting the rejected code would be rejected again, and a code in
|
|
669
|
+
// its last seconds expires while Entra validates it, so wait out the
|
|
670
|
+
// rest of the period in both cases. Entra's own form stays on screen
|
|
671
|
+
// meanwhile, and the 5-minute budget is checked between polls.
|
|
672
|
+
const remaining = secondsUntilFreshCode(this.config.totpUri);
|
|
673
|
+
if (isRetry || remaining < MIN_CODE_LIFETIME_S)
|
|
674
|
+
await page.waitForTimeout(Math.ceil(remaining * 1000) + 100);
|
|
675
|
+
code = generateTotp(this.config.totpUri);
|
|
676
|
+
}
|
|
677
|
+
else {
|
|
678
|
+
code = await this.config.requestMfaCode();
|
|
679
|
+
}
|
|
535
680
|
if (!/^\d{6,8}$/.test(code))
|
|
536
681
|
throw new UnsupportedAuthenticationError("The MFA code must contain 6-8 digits.");
|
|
537
682
|
await input.fill(code);
|
|
@@ -541,8 +686,82 @@ export class PurdueSSOFlow {
|
|
|
541
686
|
else
|
|
542
687
|
await input.press("Enter");
|
|
543
688
|
log("INFO", "Authenticator code submitted");
|
|
689
|
+
// The code itself is never logged, here or anywhere else.
|
|
690
|
+
if (automatic)
|
|
691
|
+
devActivity("mfa_code_submitted");
|
|
544
692
|
return true;
|
|
545
693
|
}
|
|
694
|
+
/**
|
|
695
|
+
* Whether this poll may answer the challenge from the saved enrollment.
|
|
696
|
+
*
|
|
697
|
+
* Gated on the identity provider and the challenge on screen, never on a
|
|
698
|
+
* school's URL: only Microsoft Entra's verification-code form is driven
|
|
699
|
+
* here, so the test is whether Entra is the page in front of us.
|
|
700
|
+
*
|
|
701
|
+
* - no saved enrollment: always false, and not one extra page query is made
|
|
702
|
+
* - Duo answered a challenge: Duo codes come from a different enrollment
|
|
703
|
+
* - Entra's passwordless approval view: the phone IS the first factor there,
|
|
704
|
+
* and a code cannot stand in for it (see the passwordless option, #206)
|
|
705
|
+
* - Entra never offered a code method: fall back to announce-and-approve
|
|
706
|
+
*/
|
|
707
|
+
async automaticCodeApplicable(page, duoObserved, exhausted) {
|
|
708
|
+
if (!this.config.totpUri || duoObserved || exhausted)
|
|
709
|
+
return false;
|
|
710
|
+
if (new URL(page.url()).hostname !== "login.microsoftonline.com")
|
|
711
|
+
return false;
|
|
712
|
+
return !await this.anyVisible(page, PASSWORDLESS_APPROVAL_SELECTORS);
|
|
713
|
+
}
|
|
714
|
+
/**
|
|
715
|
+
* Move Entra from whatever method it defaulted to onto its verification-code
|
|
716
|
+
* form, one control per login at most.
|
|
717
|
+
*
|
|
718
|
+
* - `codeForm`: the form is already up; submitMfaCode owns it
|
|
719
|
+
* - `spent`: a code has already been submitted, so nothing more to switch to
|
|
720
|
+
* - `clicked`: a method control was clicked; re-poll the new page
|
|
721
|
+
* - `noSwitch`: Entra is offering no way to reach a code
|
|
722
|
+
*/
|
|
723
|
+
async selectCodeMethod(page) {
|
|
724
|
+
if (await this.firstVisible(page, MFA_CODE_SELECTORS))
|
|
725
|
+
return "codeForm";
|
|
726
|
+
if (this.mfaCodeAttempts > 0)
|
|
727
|
+
return "spent";
|
|
728
|
+
await this.assertExpectedMicrosoftAccount(page);
|
|
729
|
+
for (const [step, label] of CODE_METHOD_STEPS) {
|
|
730
|
+
if (this.methodClicked.has(step))
|
|
731
|
+
continue;
|
|
732
|
+
const control = page.getByText(label).first();
|
|
733
|
+
if (!await control.isVisible().catch(() => false))
|
|
734
|
+
continue;
|
|
735
|
+
this.methodClicked.add(step);
|
|
736
|
+
await control.click();
|
|
737
|
+
devActivity("mfa_method_selected");
|
|
738
|
+
return "clicked";
|
|
739
|
+
}
|
|
740
|
+
return "noSwitch";
|
|
741
|
+
}
|
|
742
|
+
/**
|
|
743
|
+
* Refuse to drive Entra's method pages, or type a code into them, unless
|
|
744
|
+
* this really is Microsoft showing the account we are signing in as. A code
|
|
745
|
+
* typed into someone else's session is a second factor handed to them.
|
|
746
|
+
*/
|
|
747
|
+
async assertExpectedMicrosoftAccount(page) {
|
|
748
|
+
if (new URL(page.url()).hostname !== "login.microsoftonline.com" || !this.config.username) {
|
|
749
|
+
throw new UnsupportedAuthenticationError("Automatic code entry requires Microsoft's own sign-in page and a configured account.");
|
|
750
|
+
}
|
|
751
|
+
const expected = signInName(this.config.username, this.config.baseUrl).toLowerCase();
|
|
752
|
+
for (const selector of ACCOUNT_LABEL_SELECTORS) {
|
|
753
|
+
const account = page.locator(selector).first();
|
|
754
|
+
// These are alternative layouts, not required controls, and textContent()
|
|
755
|
+
// auto-waits a missing element out for 30 seconds on every MFA poll.
|
|
756
|
+
if (await account.count() === 0)
|
|
757
|
+
continue;
|
|
758
|
+
const value = await account.textContent({ timeout: 1000 }).catch(() => null);
|
|
759
|
+
const shown = value?.match(/[\w.+-]+@[\w.-]+\.[a-z]{2,}/i)?.[0]?.toLowerCase();
|
|
760
|
+
if (shown && shown !== expected) {
|
|
761
|
+
throw new UnsupportedAuthenticationError("Microsoft is showing another account. Automatic code entry stopped.");
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
}
|
|
546
765
|
/**
|
|
547
766
|
* Tick Entra's "Don't ask again" box once; retry absent controls on later MFA polls.
|
|
548
767
|
* Opt-in: without an explicit true the box is left alone and the outcome
|
package/build/auth/sso-flow.js
CHANGED
|
@@ -20,6 +20,15 @@ export class UnsupportedAuthenticationError extends BrowserAuthError {
|
|
|
20
20
|
this.name = "UnsupportedAuthenticationError";
|
|
21
21
|
}
|
|
22
22
|
}
|
|
23
|
+
/**
|
|
24
|
+
* An automatic verification-code sign-in that never reached Brightspace.
|
|
25
|
+
* Distinct from MfaApprovalError because nobody was ever asked to approve
|
|
26
|
+
* anything: telling the user to check their phone would be a lie, and the
|
|
27
|
+
* caller-facing guidance has to say "still working" instead.
|
|
28
|
+
* Idea from ElliotDrel/brightspace-mcp-server (branch codex/purdue-totp).
|
|
29
|
+
*/
|
|
30
|
+
export class AutomaticCodeAuthenticationError extends UnsupportedAuthenticationError {
|
|
31
|
+
}
|
|
23
32
|
export class MfaApprovalError extends BrowserAuthError {
|
|
24
33
|
code = "AUTH_MFA_FAILED";
|
|
25
34
|
/**
|
|
@@ -40,16 +49,24 @@ export class MfaApprovalError extends BrowserAuthError {
|
|
|
40
49
|
* else uses the default flow, which already covers the common Shibboleth,
|
|
41
50
|
* CAS, and Microsoft Entra forms.
|
|
42
51
|
*/
|
|
43
|
-
export function createSSOFlow(config, requestMfaCode, onMfaChallenge) {
|
|
52
|
+
export function createSSOFlow(config, requestMfaCode, onMfaChallenge, onAutomaticPending) {
|
|
44
53
|
const credentials = {
|
|
45
54
|
username: config.username,
|
|
46
55
|
password: config.password,
|
|
56
|
+
// Handed to every flow, gated by none: the enrollment is saved per
|
|
57
|
+
// account, and the only code that reads it answers Microsoft Entra's
|
|
58
|
+
// verification-code form on login.microsoftonline.com. Gating on a
|
|
59
|
+
// school's own URL instead would hard-code one tenant into a
|
|
60
|
+
// school-agnostic server, and would still be the wrong test — what
|
|
61
|
+
// matters is the identity provider and the challenge it is showing.
|
|
62
|
+
totpUri: config.totpUri,
|
|
47
63
|
baseUrl: config.baseUrl,
|
|
48
64
|
headless: config.headless,
|
|
49
65
|
rememberMfa: config.rememberMfa,
|
|
50
66
|
passwordless: config.passwordless,
|
|
51
67
|
requestMfaCode,
|
|
52
68
|
onMfaChallenge,
|
|
69
|
+
onAutomaticPending,
|
|
53
70
|
};
|
|
54
71
|
if (isTUDelftBrightspace(config.baseUrl)) {
|
|
55
72
|
return new TUDelftSSOFlow(credentials);
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Brightspace MCP Server
|
|
3
|
+
* Copyright (c) 2026 Rohan Muppa. All rights reserved.
|
|
4
|
+
* Licensed under MIT — see LICENSE file for details.
|
|
5
|
+
*
|
|
6
|
+
* RFC 6238 time-based one-time passwords, generated locally from an
|
|
7
|
+
* enrollment the user saved themselves. Ported from
|
|
8
|
+
* ElliotDrel/brightspace-mcp-server (branch codex/purdue-totp, PR #54).
|
|
9
|
+
*
|
|
10
|
+
* This is the SECOND factor, so the seed sitting beside the password on one
|
|
11
|
+
* machine collapses two factors into one THERE: it still defeats a remote
|
|
12
|
+
* attacker who has only the password, and it defends against nothing already
|
|
13
|
+
* running as this user. That is why it is opt-in and off by default — nothing
|
|
14
|
+
* here runs unless the user deliberately saved an enrollment.
|
|
15
|
+
*/
|
|
16
|
+
import { createHmac } from "node:crypto";
|
|
17
|
+
function parseEnrollment(uri) {
|
|
18
|
+
let url;
|
|
19
|
+
try {
|
|
20
|
+
url = new URL(uri);
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
throw new Error("Invalid authenticator enrollment URI.");
|
|
24
|
+
}
|
|
25
|
+
const secret = url.searchParams.get("secret")?.toUpperCase().replace(/\s/g, "") ?? "";
|
|
26
|
+
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
27
|
+
if (url.protocol !== "otpauth:" || url.hostname !== "totp" || !/^[A-Z2-7]+=*$/.test(secret)) {
|
|
28
|
+
throw new Error("Invalid authenticator enrollment URI.");
|
|
29
|
+
}
|
|
30
|
+
const raw = secret.replace(/=+$/, "");
|
|
31
|
+
let bits = 0, buffer = 0;
|
|
32
|
+
const bytes = [];
|
|
33
|
+
for (const char of raw) {
|
|
34
|
+
buffer = (buffer << 5) | alphabet.indexOf(char);
|
|
35
|
+
bits += 5;
|
|
36
|
+
if (bits >= 8) {
|
|
37
|
+
bits -= 8;
|
|
38
|
+
bytes.push((buffer >>> bits) & 255);
|
|
39
|
+
buffer &= (1 << bits) - 1;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const algorithm = (url.searchParams.get("algorithm") ?? "SHA1").toLowerCase();
|
|
43
|
+
const digits = Number(url.searchParams.get("digits") ?? "6");
|
|
44
|
+
const period = Number(url.searchParams.get("period") ?? "30");
|
|
45
|
+
if (bytes.length < 10 || buffer !== 0 || !["sha1", "sha256", "sha512"].includes(algorithm)
|
|
46
|
+
|| ![6, 7, 8].includes(digits) || !Number.isSafeInteger(period) || period <= 0) {
|
|
47
|
+
throw new Error("Invalid authenticator enrollment URI.");
|
|
48
|
+
}
|
|
49
|
+
return { secret: Buffer.from(bytes), algorithm: algorithm, digits, period };
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Accept either a full `otpauth://` URI (what a QR code encodes) or the bare
|
|
53
|
+
* base32 setup key Microsoft shows behind "Can't scan image?", and return the
|
|
54
|
+
* canonical URI to store. A bare key is wrapped under the account's own name:
|
|
55
|
+
* no school or issuer is baked in, because the enrollment belongs to one
|
|
56
|
+
* account at one school, not to a particular tenant.
|
|
57
|
+
*/
|
|
58
|
+
export function normalizeTotpEnrollment(input, username) {
|
|
59
|
+
const trimmed = input.trim();
|
|
60
|
+
const key = trimmed.toUpperCase().replace(/\s/g, "");
|
|
61
|
+
const uri = trimmed.startsWith("otpauth://") ? trimmed
|
|
62
|
+
: `otpauth://totp/${encodeURIComponent(username || "account")}?secret=${key}`;
|
|
63
|
+
parseEnrollment(uri);
|
|
64
|
+
return uri;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Seconds of life left in the code on screen now. A code submitted in its
|
|
68
|
+
* last moments is rejected by the time Entra validates it, so the caller
|
|
69
|
+
* waits this out rather than spending one of its three attempts.
|
|
70
|
+
*/
|
|
71
|
+
export function secondsUntilFreshCode(uri, now = Date.now()) {
|
|
72
|
+
const { period } = parseEnrollment(uri);
|
|
73
|
+
return period - (now / 1000 % period);
|
|
74
|
+
}
|
|
75
|
+
export function generateTotp(uri, now = Date.now()) {
|
|
76
|
+
const { secret, algorithm, digits, period } = parseEnrollment(uri);
|
|
77
|
+
const counter = Buffer.alloc(8);
|
|
78
|
+
counter.writeBigUInt64BE(BigInt(Math.floor(now / 1000 / period)));
|
|
79
|
+
const digest = createHmac(algorithm, secret).update(counter).digest();
|
|
80
|
+
const offset = digest[digest.length - 1] & 15;
|
|
81
|
+
const value = (digest.readUInt32BE(offset) & 0x7fffffff) % 10 ** digits;
|
|
82
|
+
return String(value).padStart(digits, "0");
|
|
83
|
+
}
|
package/build/auth-cli.js
CHANGED
|
@@ -12,7 +12,8 @@ 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 { challengeMarker } from "./auth/mfa-challenge.js";
|
|
15
|
+
import { AUTOMATIC_PENDING_MARKER, challengeMarker } from "./auth/mfa-challenge.js";
|
|
16
|
+
import { configureDevActivity } from "./utils/dev-activity.js";
|
|
16
17
|
import { AUTH_COMMAND, SETUP_COMMAND } from "./utils/commands.js";
|
|
17
18
|
import { initUpdateChecker, peekUpdateNotice } from "./utils/update-checker.js";
|
|
18
19
|
import { reexecLatestIfStale } from "./utils/self-update.js";
|
|
@@ -49,6 +50,9 @@ async function main() {
|
|
|
49
50
|
const skewCheck = detectSkew(pkg.version);
|
|
50
51
|
try {
|
|
51
52
|
const config = await loadConfig();
|
|
53
|
+
// The sign-in itself runs here, not in the server, so the dev activity log
|
|
54
|
+
// only sees its MFA events if this process is configured for them too.
|
|
55
|
+
configureDevActivity(config.sessionDir);
|
|
52
56
|
console.error(`\n=== Brightspace Authentication v${pkg.version} ===\n`);
|
|
53
57
|
console.error(config.headless
|
|
54
58
|
? "Authentication runs headlessly. MFA numbers and code prompts appear here."
|
|
@@ -70,7 +74,13 @@ async function main() {
|
|
|
70
74
|
const onPhase = automatic
|
|
71
75
|
? (phase, elapsedMs) => console.log(formatPhaseMarker(phase, elapsedMs))
|
|
72
76
|
: undefined;
|
|
73
|
-
|
|
77
|
+
// No digits and nothing to approve: just "this sign-in is answering its
|
|
78
|
+
// own verification code", so the parent can say so instead of sending the
|
|
79
|
+
// user to their phone.
|
|
80
|
+
const onAutomaticPending = automatic
|
|
81
|
+
? () => console.log(AUTOMATIC_PENDING_MARKER)
|
|
82
|
+
: undefined;
|
|
83
|
+
await new BrowserAuth(config, { requestMfaCode: codePrompt, onMfaChallenge, onPhase, onAutomaticPending }).authenticate({
|
|
74
84
|
automatic,
|
|
75
85
|
onAuthenticated: async (token) => {
|
|
76
86
|
await tokenManager.setToken(token);
|
|
@@ -95,7 +105,9 @@ async function main() {
|
|
|
95
105
|
// Another process's sign-in holds the lock and is showing a challenge:
|
|
96
106
|
// pass it on, so this caller can tell its user what to approve.
|
|
97
107
|
if (automatic && error instanceof AuthenticationInProgressError && error.challenge) {
|
|
98
|
-
console.log(
|
|
108
|
+
console.log(error.challenge.kind === "automatic"
|
|
109
|
+
? AUTOMATIC_PENDING_MARKER
|
|
110
|
+
: challengeMarker(error.challenge.numberMatch));
|
|
99
111
|
}
|
|
100
112
|
process.exitCode = error instanceof NativeCredentialStoreError ? 5
|
|
101
113
|
: code === "AUTH_IN_PROGRESS" ? 2
|
package/build/setup.js
CHANGED
|
@@ -14,6 +14,7 @@ import { spawn } from "node:child_process";
|
|
|
14
14
|
import { fileURLToPath } from "node:url";
|
|
15
15
|
import { configStoreExists, getConfigStorePath, loadConfigStore, } from "./utils/config-store.js";
|
|
16
16
|
import { saveSecureConfig } from "./utils/secure-config.js";
|
|
17
|
+
import { normalizeTotpEnrollment } from "./auth/totp.js";
|
|
17
18
|
import { writeFileAtomicSync } from "./utils/atomic-write.js";
|
|
18
19
|
import { AUTH_COMMAND, DOCTOR_COMMAND } from "./utils/commands.js";
|
|
19
20
|
import { classifyRegistration, cliMcpClients, inspectCliMcpClient, isCliAvailable, registerCliMcpClient, serverCommand, } from "./utils/mcp-client-cli.js";
|
|
@@ -360,6 +361,10 @@ export function buildConfigToSave(existing, answers) {
|
|
|
360
361
|
// Always the freshly typed one: a carried v1 plaintext password would
|
|
361
362
|
// otherwise be the value written to the native store.
|
|
362
363
|
password: answers.passwordless ? undefined : answers.password,
|
|
364
|
+
// Only ever the freshly typed one. Never read back out of `carried`: the
|
|
365
|
+
// enrollment lives in the credential store, never in config.json, so a
|
|
366
|
+
// value here could only have come from this run.
|
|
367
|
+
...(answers.totpUri ? { totpUri: answers.totpUri } : {}),
|
|
363
368
|
headless: answers.headless,
|
|
364
369
|
};
|
|
365
370
|
if (answers.rememberMfa !== undefined)
|
|
@@ -528,6 +533,29 @@ async function main() {
|
|
|
528
533
|
}
|
|
529
534
|
console.log("");
|
|
530
535
|
}
|
|
536
|
+
// ── Step 3b: Authenticator enrollment (hidden, optional) ─────────
|
|
537
|
+
// Opt-in and off by default. Asked without naming a school: whether a code
|
|
538
|
+
// can be typed depends on the identity provider and the challenge it shows,
|
|
539
|
+
// which is only known at sign-in time.
|
|
540
|
+
console.log(dim(" Optional. If your school asks for a 6-digit code from an authenticator app, this computer can generate it"));
|
|
541
|
+
console.log(dim(" for you, so automatic sign-ins need nothing from your phone. Paste the SETUP KEY (or otpauth:// link) you"));
|
|
542
|
+
console.log(dim(" got when enrolling the app — a code showing on screen right now will not work."));
|
|
543
|
+
console.log(dim(" The tradeoff: the key is stored beside your password, so on THIS computer the two factors become one."));
|
|
544
|
+
console.log(dim(" It still stops anyone who only has your password. Press Enter to skip, or to keep a key you saved before."));
|
|
545
|
+
let totpUri;
|
|
546
|
+
while (true) {
|
|
547
|
+
const input = await askPassword(" Authenticator setup key or otpauth:// link (optional): ");
|
|
548
|
+
if (!input)
|
|
549
|
+
break;
|
|
550
|
+
try {
|
|
551
|
+
totpUri = normalizeTotpEnrollment(input, username);
|
|
552
|
+
break;
|
|
553
|
+
}
|
|
554
|
+
catch {
|
|
555
|
+
console.log(yellow(" That is not a setup key or otpauth:// link. Paste the enrollment key, or press Enter to skip."));
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
console.log("");
|
|
531
559
|
// Re-open readline for remaining prompts
|
|
532
560
|
let rl2 = readline.createInterface({
|
|
533
561
|
input: process.stdin,
|
|
@@ -590,6 +618,7 @@ async function main() {
|
|
|
590
618
|
baseUrl,
|
|
591
619
|
username,
|
|
592
620
|
password,
|
|
621
|
+
totpUri,
|
|
593
622
|
headless,
|
|
594
623
|
rememberMfa,
|
|
595
624
|
passwordless,
|
|
@@ -599,6 +628,8 @@ async function main() {
|
|
|
599
628
|
console.log(green(passwordless
|
|
600
629
|
? " No password saved: each sign-in will wait for your phone approval."
|
|
601
630
|
: " Password saved in your operating system credential store."));
|
|
631
|
+
if (totpUri)
|
|
632
|
+
console.log(green(" Authenticator enrollment saved in your operating system credential store."));
|
|
602
633
|
console.log(green(" Config saved to: " + getConfigStorePath()));
|
|
603
634
|
console.log("");
|
|
604
635
|
// ── Step 6: Authenticate now? ────────────────────────────────────
|
|
@@ -105,6 +105,10 @@ const AUTH_FAILURE_GUIDANCE = {
|
|
|
105
105
|
`Run \`${AUTH_COMMAND}\` in a terminal (from your home folder) to complete it with the number visible.`,
|
|
106
106
|
failed: `The sign-in did not complete. Run \`${AUTH_COMMAND}\` in a terminal (from your home folder) to see why, ` +
|
|
107
107
|
"or `brightspace-setup` if your saved school or username is wrong.",
|
|
108
|
+
automaticPending: "Brightspace sign-in is answering its own verification code from the saved authenticator " +
|
|
109
|
+
"enrollment. No phone approval is being requested, so don't ask the user to approve anything. " +
|
|
110
|
+
"Call this tool again right away: the next call joins the same sign-in and returns the result " +
|
|
111
|
+
"as soon as it completes.",
|
|
108
112
|
mfaPending: "Approve the sign-in request on your phone (Microsoft Authenticator or Duo). " +
|
|
109
113
|
"Tell the user that, then call this tool again right away without waiting for them to " +
|
|
110
114
|
"confirm: each call waits up to 45 seconds for the approval and returns the result as soon " +
|
|
@@ -124,12 +128,17 @@ const AUTH_FAILURE_GUIDANCE = {
|
|
|
124
128
|
*/
|
|
125
129
|
const MFA_ALREADY_REPORTED = "Brightspace sign-in is already in progress; another call in this batch has the number to " +
|
|
126
130
|
"approve. Retry this call once the sign-in completes.";
|
|
131
|
+
const AUTOMATIC_ALREADY_REPORTED = "Brightspace sign-in is already in progress, answering its own verification code; another call " +
|
|
132
|
+
"in this batch has the details. Retry this call once the sign-in completes. Don't ask the user " +
|
|
133
|
+
"to approve anything.";
|
|
127
134
|
function authFailureMessage(error) {
|
|
128
135
|
// Another call in this parallel batch already carries the number and the
|
|
129
136
|
// instructions (issue #212). Repeating them in every response made a batch
|
|
130
137
|
// unreadable; one short line is enough for this call.
|
|
131
138
|
if (error.kind === "mfaPending" && error.duplicate)
|
|
132
139
|
return MFA_ALREADY_REPORTED;
|
|
140
|
+
if (error.kind === "automaticPending" && error.duplicate)
|
|
141
|
+
return AUTOMATIC_ALREADY_REPORTED;
|
|
133
142
|
if (error.kind === "mfaPending" && error.numberMatch) {
|
|
134
143
|
return `Open Microsoft Authenticator and enter ${error.numberMatch} within 5 minutes. ` +
|
|
135
144
|
"Tell the user that number, then call this tool again right away without waiting for them " +
|
|
@@ -17,8 +17,8 @@ export function loadConfigStore() {
|
|
|
17
17
|
return JSON.parse(raw);
|
|
18
18
|
}
|
|
19
19
|
export function saveConfigStore(config) {
|
|
20
|
-
if (config.password !== undefined) {
|
|
21
|
-
throw new Error("
|
|
20
|
+
if (config.password !== undefined || config.totpUri !== undefined) {
|
|
21
|
+
throw new Error("Secrets must be saved in the native credential store before saving configuration.");
|
|
22
22
|
}
|
|
23
23
|
const isWindows = process.platform === "win32";
|
|
24
24
|
if (!fs.existsSync(CONFIG_DIR)) {
|
package/build/utils/config.js
CHANGED
|
@@ -9,6 +9,8 @@ import { createHash } from "node:crypto";
|
|
|
9
9
|
import dotenv from "dotenv";
|
|
10
10
|
import { configStoreExists, getConfigStorePath, loadConfigStore } from "./config-store.js";
|
|
11
11
|
import { resolveStoredPassword } from "./secure-config.js";
|
|
12
|
+
import { getStoredTotpUri } from "../auth/credential-store.js";
|
|
13
|
+
import { normalizeTotpEnrollment } from "../auth/totp.js";
|
|
12
14
|
import { migrateLegacyState } from "../auth/legacy-state.js";
|
|
13
15
|
export async function loadConfig() {
|
|
14
16
|
dotenv.config({ quiet: true });
|
|
@@ -94,6 +96,20 @@ export async function loadConfig() {
|
|
|
94
96
|
const baseUrl = configuredUrl.origin;
|
|
95
97
|
const username = process.env.D2L_USERNAME || store?.username;
|
|
96
98
|
const password = passwordless ? undefined : await resolveStoredPassword(baseUrl, username, store);
|
|
99
|
+
// Opt-in: only an enrollment the user deliberately saved makes the Entra
|
|
100
|
+
// sign-in answer a verification-code challenge itself. With none, every MFA
|
|
101
|
+
// challenge is handled exactly as before. The keyring entry is per account;
|
|
102
|
+
// D2L_TOTP_SECRET exists for CI and containers, where there is no keyring,
|
|
103
|
+
// and is deliberately weaker (see .env.example) so it is never the default.
|
|
104
|
+
const envTotpSecret = readEnvSecret(process.env.D2L_TOTP_SECRET, "D2L_TOTP_SECRET");
|
|
105
|
+
const totpUri = envTotpSecret
|
|
106
|
+
? normalizeTotpEnrollment(envTotpSecret, username ?? "")
|
|
107
|
+
// A credential store that cannot be read means "no enrollment saved",
|
|
108
|
+
// which is the default anyway. It must never be a startup failure: this is
|
|
109
|
+
// an optional extra, and resolveStoredPassword above is already the place
|
|
110
|
+
// that reports a locked or missing store, loudly, for the credential the
|
|
111
|
+
// server genuinely cannot work without.
|
|
112
|
+
: username ? (await getStoredTotpUri(baseUrl, username).catch(() => null)) ?? undefined : undefined;
|
|
97
113
|
// A new account must never inherit another account's cookies, even at the same school.
|
|
98
114
|
const sessionDir = accountSessionDirectory(sessionRoot, baseUrl, username);
|
|
99
115
|
const legacyMigration = sessionDir !== sessionRoot ? await migrateLegacyState(sessionRoot) : undefined;
|
|
@@ -108,6 +124,7 @@ export async function loadConfig() {
|
|
|
108
124
|
passwordless,
|
|
109
125
|
username,
|
|
110
126
|
password,
|
|
127
|
+
totpUri,
|
|
111
128
|
campus: process.env.D2L_CAMPUS || store?.campus,
|
|
112
129
|
envAccessToken,
|
|
113
130
|
envSessionCookie,
|
|
@@ -24,7 +24,8 @@ function write(event, fields, context = calls.getStore(), idleMs, sinceSuccessMs
|
|
|
24
24
|
if (!directory)
|
|
25
25
|
return;
|
|
26
26
|
if (!["tool_started", "tool_finished", "http_response", "auth_required", "token_mint_started",
|
|
27
|
-
"token_mint_finished", "recovery_started", "mfa_observed", "recovery_finished", "auth_phase"
|
|
27
|
+
"token_mint_finished", "recovery_started", "mfa_observed", "recovery_finished", "auth_phase",
|
|
28
|
+
"mfa_method_selected", "mfa_code_submitted"].includes(event))
|
|
28
29
|
return;
|
|
29
30
|
const at = new Date().toISOString();
|
|
30
31
|
const record = { at, runId, pid: process.pid, event };
|
|
@@ -36,7 +37,8 @@ function write(event, fields, context = calls.getStore(), idleMs, sinceSuccessMs
|
|
|
36
37
|
}
|
|
37
38
|
if (["success", "error", "sessionExpired", "transport"].includes(fields.outcome ?? ""))
|
|
38
39
|
record.outcome = fields.outcome;
|
|
39
|
-
if (["busy", "cooldown", "unsupported", "secureStorage", "transport", "timeout", "failed", "mfaPending"
|
|
40
|
+
if (["busy", "cooldown", "unsupported", "secureStorage", "transport", "timeout", "failed", "mfaPending",
|
|
41
|
+
"automaticPending", "inProgress"].includes(fields.reason ?? ""))
|
|
40
42
|
record.reason = fields.reason;
|
|
41
43
|
if (AUTH_PHASES.includes(fields.phase ?? ""))
|
|
42
44
|
record.phase = fields.phase;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { deleteStoredPassword, getStoredPassword, setStoredPassword } from "../auth/credential-store.js";
|
|
1
|
+
import { deleteStoredPassword, getStoredPassword, getStoredTotpUri, setStoredPassword, setStoredTotpUri } from "../auth/credential-store.js";
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { acquireProcessLock } from "../auth/auth-lock.js";
|
|
4
|
+
import { normalizeTotpEnrollment } from "../auth/totp.js";
|
|
4
5
|
import { configStoreExists, getConfigStorePath, loadConfigStore, saveConfigStore } from "./config-store.js";
|
|
5
6
|
async function withConfigWriteLock(operation) {
|
|
6
7
|
const release = await acquireProcessLock(path.join(path.dirname(getConfigStorePath()), ".config-write.lock"));
|
|
@@ -16,7 +17,7 @@ export async function saveSecureConfig(config) {
|
|
|
16
17
|
await withConfigWriteLock(() => saveSecureConfigUnlocked(config));
|
|
17
18
|
}
|
|
18
19
|
async function saveSecureConfigUnlocked(config) {
|
|
19
|
-
const { password, ...publicConfig } = config;
|
|
20
|
+
const { password, totpUri, ...publicConfig } = config;
|
|
20
21
|
if (publicConfig.baseUrl) {
|
|
21
22
|
const url = new URL(publicConfig.baseUrl);
|
|
22
23
|
if (url.protocol !== "https:" || url.username || url.password) {
|
|
@@ -24,6 +25,16 @@ async function saveSecureConfigUnlocked(config) {
|
|
|
24
25
|
}
|
|
25
26
|
publicConfig.baseUrl = url.origin;
|
|
26
27
|
}
|
|
28
|
+
// Both secrets are validated before EITHER is written, so a mistyped
|
|
29
|
+
// enrollment cannot replace the saved password and then fail halfway
|
|
30
|
+
// through setup. The enrollment is keyed to one account at one school, so
|
|
31
|
+
// it needs both; no school is singled out — the Entra sign-in flow decides
|
|
32
|
+
// on its own whether a code can be typed.
|
|
33
|
+
// Idea from ElliotDrel/brightspace-mcp-server (branch codex/purdue-totp).
|
|
34
|
+
const enrollment = totpUri === undefined ? undefined : normalizeTotpEnrollment(totpUri, config.username ?? "");
|
|
35
|
+
if (enrollment !== undefined && (!config.baseUrl || !config.username)) {
|
|
36
|
+
throw new Error("An authenticator enrollment requires a school URL and username.");
|
|
37
|
+
}
|
|
27
38
|
if (password !== undefined) {
|
|
28
39
|
if (!config.baseUrl || !config.username || !password) {
|
|
29
40
|
throw new Error("A school URL, username, and nonempty password are required.");
|
|
@@ -33,6 +44,12 @@ async function saveSecureConfigUnlocked(config) {
|
|
|
33
44
|
throw new Error("Could not verify the password in the native credential store. Configuration was preserved.");
|
|
34
45
|
}
|
|
35
46
|
}
|
|
47
|
+
if (enrollment !== undefined) {
|
|
48
|
+
await setStoredTotpUri(config.baseUrl, config.username, enrollment);
|
|
49
|
+
if (await getStoredTotpUri(config.baseUrl, config.username) !== enrollment) {
|
|
50
|
+
throw new Error("Could not verify the authenticator enrollment in the native credential store. Configuration was preserved.");
|
|
51
|
+
}
|
|
52
|
+
}
|
|
36
53
|
if (publicConfig.passwordless && config.baseUrl && config.username) {
|
|
37
54
|
await deleteStoredPassword(config.baseUrl, config.username);
|
|
38
55
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brightspace-mcp-server",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.13.0",
|
|
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",
|