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 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?
@@ -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
- /** The owner's MFA challenge, when the caller looked it up (see BrowserAuth.authenticate). */
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
- if (!(error instanceof AuthProcessError) || error.kind !== "mfaPending" || error.duplicate)
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
- const key = error.numberMatch ?? "";
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 (!onChallenge || !childDone || !(error instanceof AuthProcessError) || error.kind !== "mfaPending")
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
- try {
292
- onChallenge(error.numberMatch);
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
- const firstChallenge = this.pendingChallenge === null;
499
- if (firstChallenge)
500
- watchAttention();
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
- if (error instanceof MfaApprovalError)
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
- return typeof numberMatch === "string" && NUMBER_MATCH.test(numberMatch) ? { numberMatch } : {};
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";
@@ -5,9 +5,11 @@
5
5
  */
6
6
  import { BrowserAuthError } from "../utils/errors.js";
7
7
  import { log } from "../utils/logger.js";
8
- import { MfaApprovalError, UnsupportedAuthenticationError } from "./sso-flow.js";
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 are EXPECTED, not verified against a live tenant from here.
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 deadline = Date.now() + MFA_TIMEOUT_MS;
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
- if (await this.submitMfaCode(page))
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
- async submitMfaCode(page) {
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
- if (this.config.headless === false)
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", "Microsoft rejected that code. Enter the current one from your authenticator app.");
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
- const code = await this.config.requestMfaCode();
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
@@ -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
- await new BrowserAuth(config, { requestMfaCode: codePrompt, onMfaChallenge, onPhase }).authenticate({
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(challengeMarker(error.challenge.numberMatch));
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("Passwords must be saved in the native credential store before saving configuration.");
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)) {
@@ -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"].includes(event))
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"].includes(fields.reason ?? ""))
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.12.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",