brightspace-mcp-server 3.4.0 → 3.5.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 +3 -1
- package/build/api/cache.js +14 -1
- package/build/api/client.js +17 -5
- package/build/api/rate-limiter.js +24 -13
- package/build/auth/auth-cooldown.js +28 -4
- package/build/auth/auth-runner.js +194 -27
- package/build/auth/browser-auth.js +2 -2
- package/build/auth/credential-store.js +14 -1
- package/build/auth/duo-mfa.js +90 -6
- package/build/auth/purdue-sso.js +19 -2
- package/build/auth/session-store.js +22 -1
- package/build/auth/sso-flow.js +2 -1
- package/build/auth/suny-sso.js +8 -0
- package/build/auth-cli.js +8 -1
- package/build/setup.js +123 -25
- package/build/tools/download-file.js +24 -6
- package/build/tools/get-announcements.js +11 -5
- package/build/tools/get-assignments.js +11 -5
- package/build/tools/get-course-content.js +12 -2
- package/build/tools/get-my-grades.js +11 -5
- package/build/tools/get-upcoming-due-dates.js +15 -5
- package/build/tools/tool-helpers.js +8 -6
- package/build/update.js +38 -5
- package/build/utils/atomic-write.js +38 -12
- package/build/utils/deep-links.js +17 -3
- package/build/utils/download-helpers.js +11 -5
- package/build/utils/file-validator.js +71 -5
- package/build/utils/html-converter.js +66 -0
- package/build/utils/logger.js +19 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -101,7 +101,9 @@ If you miss an MFA request, automatic browser authentication pauses for five min
|
|
|
101
101
|
npx -y brightspace-mcp-server@latest auth
|
|
102
102
|
```
|
|
103
103
|
|
|
104
|
-
|
|
104
|
+
Run it from your home folder. On macOS, a terminal that lacks Files and Folders permission (the terminal panel inside Claude Desktop, or a fresh editor terminal) cannot start `npx` from inside Documents, Desktop, or Downloads — it fails with `EPERM: process.cwd failed … uv_cwd` before the server runs. The same applies if your AI client launches the server with one of those folders as its working directory; grant the app access under System Settings → Privacy & Security → Files and Folders, or start the server elsewhere.
|
|
105
|
+
|
|
106
|
+
**MFA at Purdue** commonly uses Microsoft Authenticator number matching (some schools use Duo instead). When a sign-in needs it, the tool call itself returns quickly with the number to enter, rather than sitting silent for up to five minutes — approve it on your phone, then call the tool again; sign-in finishes in the background in the meantime. Google Authenticator and other one-time-code apps work too, with no setting to change: run the auth command above in a terminal and it prompts for the code when your provider asks for one. Pick the visible-browser option during setup only if your identity provider needs interaction the server cannot drive. The MCP also sends authentication progress as logging notifications to clients that display them, useful if you don't see the number in the tool response for some reason.
|
|
105
107
|
|
|
106
108
|
## What You Can Ask About
|
|
107
109
|
|
package/build/api/cache.js
CHANGED
|
@@ -22,12 +22,25 @@ export class TTLCache {
|
|
|
22
22
|
timerId.unref();
|
|
23
23
|
}
|
|
24
24
|
// Store entry
|
|
25
|
-
this.cache.set(key, { data: value, timerId });
|
|
25
|
+
this.cache.set(key, { data: value, timerId, storedAt: Date.now() });
|
|
26
26
|
}
|
|
27
27
|
get(key) {
|
|
28
28
|
const entry = this.cache.get(key);
|
|
29
29
|
return entry?.data;
|
|
30
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* How long ago this key was written, in milliseconds, or undefined when it
|
|
33
|
+
* is not cached.
|
|
34
|
+
*
|
|
35
|
+
* One key can be written under several TTLs: two tools ask for the same
|
|
36
|
+
* path with different freshness requirements. An entry another caller kept
|
|
37
|
+
* alive for an hour is still too old for a caller that asked for ten
|
|
38
|
+
* minutes, and only its age can say so.
|
|
39
|
+
*/
|
|
40
|
+
ageOf(key) {
|
|
41
|
+
const entry = this.cache.get(key);
|
|
42
|
+
return entry ? Date.now() - entry.storedAt : undefined;
|
|
43
|
+
}
|
|
31
44
|
has(key) {
|
|
32
45
|
return this.cache.has(key);
|
|
33
46
|
}
|
package/build/api/client.js
CHANGED
|
@@ -103,9 +103,12 @@ export class D2LApiClient {
|
|
|
103
103
|
this.cacheTTLs = { ...DEFAULT_CACHE_TTLS, ...options.cacheTTLs };
|
|
104
104
|
// Initialize cache and rate limiter
|
|
105
105
|
this.cache = new TTLCache();
|
|
106
|
+
// Sized for the per-course fan-out the tools do (a dozen courses is ~60
|
|
107
|
+
// requests); at 3/s that took ~17 s once the limiter actually reserved
|
|
108
|
+
// tokens. Brightspace 429s are still retried with Retry-After.
|
|
106
109
|
const rateLimitConfig = options.rateLimitConfig ?? {
|
|
107
|
-
capacity:
|
|
108
|
-
refillRate:
|
|
110
|
+
capacity: 20,
|
|
111
|
+
refillRate: 8,
|
|
109
112
|
};
|
|
110
113
|
this.rateLimiter = new TokenBucket(rateLimitConfig.capacity, rateLimitConfig.refillRate);
|
|
111
114
|
log("DEBUG", `D2LApiClient initialized for ${this.baseUrl}`);
|
|
@@ -183,9 +186,18 @@ export class D2LApiClient {
|
|
|
183
186
|
async get(path, options) {
|
|
184
187
|
// Checked before the path is resolved, and keyed by the path as the caller
|
|
185
188
|
// wrote it, so a cached read needs neither version discovery nor a token.
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
+
//
|
|
190
|
+
// The entry has to be younger than this caller's own TTL, not merely
|
|
191
|
+
// still alive. One path can be written under two TTLs: the discussion
|
|
192
|
+
// forum list is thirty minutes of course content to get_discussions and
|
|
193
|
+
// ten minutes of due dates to get_upcoming_due_dates. Trusting the
|
|
194
|
+
// surviving entry served the longer caller's staleness to the shorter one.
|
|
195
|
+
if (options?.ttl) {
|
|
196
|
+
const age = this.cache.ageOf(path);
|
|
197
|
+
if (age !== undefined && age <= options.ttl) {
|
|
198
|
+
log("DEBUG", `Cache hit: ${path}`);
|
|
199
|
+
return this.cache.get(path);
|
|
200
|
+
}
|
|
189
201
|
}
|
|
190
202
|
const resolved = await this.resolvePath(path);
|
|
191
203
|
const data = await this.withAuthentication(resolved, token => this.makeRequest(resolved, token));
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
* Copyright (c) 2026 Rohan Muppa. All rights reserved.
|
|
4
4
|
* Licensed under MIT — see LICENSE file for details.
|
|
5
5
|
*/
|
|
6
|
-
// Token bucket rate limiter - allows bursts up to capacity
|
|
7
|
-
//
|
|
6
|
+
// Token bucket rate limiter - allows bursts up to capacity.
|
|
7
|
+
// Defaults live in D2LApiClient (client.ts).
|
|
8
8
|
export class TokenBucket {
|
|
9
9
|
tokens;
|
|
10
10
|
lastRefill;
|
|
@@ -25,21 +25,30 @@ export class TokenBucket {
|
|
|
25
25
|
this.tokens = Math.min(this.capacity, this.tokens + tokensToAdd);
|
|
26
26
|
this.lastRefill = now;
|
|
27
27
|
}
|
|
28
|
+
/**
|
|
29
|
+
* Take `count` tokens, waiting for them if the bucket is short.
|
|
30
|
+
*
|
|
31
|
+
* The tokens are deducted before the wait rather than after it, so
|
|
32
|
+
* concurrent callers queue behind one another. Deducting afterwards let
|
|
33
|
+
* every caller read the same empty bucket, wait for the same single token,
|
|
34
|
+
* and then wake together and take it: a fan-out across courses drained the
|
|
35
|
+
* burst and then admitted the whole remainder one refill later, which is the
|
|
36
|
+
* 429 storm this limiter exists to prevent. It also left the balance deeply
|
|
37
|
+
* negative, so the next unrelated request paid the whole debt in one wait.
|
|
38
|
+
*
|
|
39
|
+
* A negative balance is that debt, owed by the waiters already queued, and
|
|
40
|
+
* refill() pays it down at the refill rate.
|
|
41
|
+
*/
|
|
28
42
|
async consume(count = 1) {
|
|
29
43
|
this.refill();
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
44
|
+
const shortfall = count - this.tokens;
|
|
45
|
+
this.tokens -= count;
|
|
46
|
+
if (shortfall <= 0) {
|
|
47
|
+
// Enough tokens were already banked - proceed immediately.
|
|
33
48
|
return;
|
|
34
49
|
}
|
|
35
|
-
|
|
36
|
-
const tokensNeeded = count - this.tokens;
|
|
37
|
-
const waitTimeMs = (tokensNeeded / this.refillRate) * 1000;
|
|
38
|
-
// Wait for tokens to refill
|
|
50
|
+
const waitTimeMs = (shortfall / this.refillRate) * 1000;
|
|
39
51
|
await new Promise((resolve) => setTimeout(resolve, waitTimeMs));
|
|
40
|
-
// Refill and consume
|
|
41
|
-
this.refill();
|
|
42
|
-
this.tokens -= count;
|
|
43
52
|
}
|
|
44
53
|
tryConsume(count = 1) {
|
|
45
54
|
this.refill();
|
|
@@ -49,8 +58,10 @@ export class TokenBucket {
|
|
|
49
58
|
}
|
|
50
59
|
return false;
|
|
51
60
|
}
|
|
61
|
+
/** Tokens free to take right now. Never negative: an outstanding
|
|
62
|
+
* reservation is a debt, not a negative supply. */
|
|
52
63
|
get availableTokens() {
|
|
53
64
|
this.refill();
|
|
54
|
-
return this.tokens;
|
|
65
|
+
return Math.max(0, this.tokens);
|
|
55
66
|
}
|
|
56
67
|
}
|
|
@@ -19,11 +19,25 @@ export class AuthenticationCooldownError extends Error {
|
|
|
19
19
|
// stalled chat window nothing they can act on.
|
|
20
20
|
const minutes = Math.max(1, Math.ceil((retryAt - Date.now()) / 60000));
|
|
21
21
|
super(`The last sign-in prompt was not approved, so automatic sign-in is paused for ${minutes} more minute${minutes === 1 ? "" : "s"}. ` +
|
|
22
|
-
`To sign in right now, run this in a terminal: ${AUTH_COMMAND}`);
|
|
22
|
+
`To sign in right now, run this in a terminal from your home folder: ${AUTH_COMMAND}`);
|
|
23
23
|
this.retryAt = retryAt;
|
|
24
24
|
this.name = "AuthenticationCooldownError";
|
|
25
25
|
}
|
|
26
26
|
}
|
|
27
|
+
/** The recorded retry time, or undefined when the file says nothing usable. */
|
|
28
|
+
function readRetryAt(content) {
|
|
29
|
+
let status;
|
|
30
|
+
try {
|
|
31
|
+
status = JSON.parse(content);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
if (typeof status !== "object" || status === null || Array.isArray(status))
|
|
37
|
+
return undefined;
|
|
38
|
+
const retryAt = status.retryAt;
|
|
39
|
+
return typeof retryAt === "number" && Number.isFinite(retryAt) ? retryAt : undefined;
|
|
40
|
+
}
|
|
27
41
|
/** Non-secret retry metadata. Call only while holding the authentication lock. */
|
|
28
42
|
export class AuthCooldown {
|
|
29
43
|
file;
|
|
@@ -40,9 +54,19 @@ export class AuthCooldown {
|
|
|
40
54
|
return;
|
|
41
55
|
throw error;
|
|
42
56
|
}
|
|
43
|
-
const
|
|
44
|
-
if (
|
|
45
|
-
|
|
57
|
+
const retryAt = readRetryAt(content);
|
|
58
|
+
if (retryAt === undefined) {
|
|
59
|
+
// This file is non-secret retry metadata, and only a readable retryAt is
|
|
60
|
+
// evidence of anything. A truncated or non-object file used to throw out
|
|
61
|
+
// of here, which runs before the sign-in attempt — and the automatic path
|
|
62
|
+
// only clears the file after a sign-in that then never started. One
|
|
63
|
+
// damaged file disabled background sign-in for good, with nothing on
|
|
64
|
+
// screen naming the cause. Discard it and let this attempt proceed.
|
|
65
|
+
await fs.unlink(this.file).catch(() => { });
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (retryAt > Date.now())
|
|
69
|
+
throw new AuthenticationCooldownError(retryAt);
|
|
46
70
|
}
|
|
47
71
|
async recordMfaFailure() {
|
|
48
72
|
await this.write({ retryAt: Date.now() + MFA_COOLDOWN_MS });
|
|
@@ -13,17 +13,36 @@ import { AUTH_COMMAND } from "../utils/commands.js";
|
|
|
13
13
|
* Timeout for the auth process. It has to outlast the child's own MFA wait,
|
|
14
14
|
* which is five minutes: a person has to find their phone, unlock it, and read
|
|
15
15
|
* a number off the screen. A shorter parent budget would kill the child in the
|
|
16
|
-
* middle of a sign-in the user was still completing.
|
|
16
|
+
* middle of a sign-in the user was still completing. run() itself usually
|
|
17
|
+
* returns long before this fires — the moment the child reports an MFA
|
|
18
|
+
* challenge — but this timer keeps bounding the background child regardless.
|
|
17
19
|
*/
|
|
18
20
|
const AUTH_TIMEOUT_MS = 8 * 60 * 1000;
|
|
19
21
|
const KILL_GRACE_MS = 5000;
|
|
20
22
|
/**
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
23
|
+
* How long a caller that joins a background child already reported as
|
|
24
|
+
* mfaPending waits for it before re-answering with the same challenge.
|
|
25
|
+
* Long enough for an already-approved login to finish its next ~2s poll and
|
|
26
|
+
* mint a token; short enough that a not-yet-approved one still answers
|
|
27
|
+
* within this tool call instead of blocking for the rest of the 5-minute
|
|
28
|
+
* approval window.
|
|
29
|
+
*/
|
|
30
|
+
const JOIN_GRACE_MS = 5000;
|
|
31
|
+
/**
|
|
32
|
+
* The only two lines auth-cli.ts is allowed to hand back as structured data.
|
|
33
|
+
* Deliberately strict (whole line, 1-3 digits or the literal word) so this
|
|
34
|
+
* can never become a channel for arbitrary child-process text to reach a
|
|
35
|
+
* tool response — anything that doesn't match exactly is just another log
|
|
36
|
+
* line.
|
|
25
37
|
*/
|
|
26
38
|
const MFA_NUMBER_MARKER = /^MFA_NUMBER:(\d{1,3})$/;
|
|
39
|
+
const MFA_PENDING_MARKER = /^MFA_PENDING$/;
|
|
40
|
+
/** The mfaPending kind and message, with or without number-match digits. */
|
|
41
|
+
function mfaPendingFailure(numberMatch) {
|
|
42
|
+
return numberMatch
|
|
43
|
+
? ["mfaPending", `Open Microsoft Authenticator and enter ${numberMatch} within 5 minutes, then try again.`]
|
|
44
|
+
: ["mfaPending", "An MFA approval was not completed in time. Try again."];
|
|
45
|
+
}
|
|
27
46
|
export class AuthProcessError extends AuthError {
|
|
28
47
|
kind;
|
|
29
48
|
numberMatch;
|
|
@@ -93,6 +112,12 @@ function forwardLines(stream, emit) {
|
|
|
93
112
|
*
|
|
94
113
|
* The child inherits the parent's resolved environment and working directory,
|
|
95
114
|
* so both processes read the same account configuration and .env file.
|
|
115
|
+
*
|
|
116
|
+
* run() settles as soon as the child reports an MFA challenge (with or
|
|
117
|
+
* without a number to display) rather than waiting for the child to exit —
|
|
118
|
+
* a tool call must not block for the whole approval window. The child keeps
|
|
119
|
+
* running in the background; a later run() call joins that background child
|
|
120
|
+
* instead of spawning a second one.
|
|
96
121
|
*/
|
|
97
122
|
export class AuthRunner {
|
|
98
123
|
/**
|
|
@@ -106,6 +131,22 @@ export class AuthRunner {
|
|
|
106
131
|
* caller await the same login and read the same outcome.
|
|
107
132
|
*/
|
|
108
133
|
inFlight = null;
|
|
134
|
+
/**
|
|
135
|
+
* The child from a login that answered its caller early (an MFA challenge
|
|
136
|
+
* was reported) and is still running in the background. Set for the
|
|
137
|
+
* duration of every spawned child, not just the early-answer case, so a
|
|
138
|
+
* caller who joins after the answer already exists resolves immediately.
|
|
139
|
+
*/
|
|
140
|
+
childDone = null;
|
|
141
|
+
/**
|
|
142
|
+
* The last MFA challenge the current background child reported, if any.
|
|
143
|
+
* Set the moment a marker settles a caller early, cleared alongside
|
|
144
|
+
* childDone. Lets a later joiner re-answer immediately instead of
|
|
145
|
+
* discovering the challenge is stale only after blocking on childDone.
|
|
146
|
+
*/
|
|
147
|
+
pendingChallenge = null;
|
|
148
|
+
/** Resolves on the next marker from the current child; null between children. */
|
|
149
|
+
challengeSignal = null;
|
|
109
150
|
scriptPath;
|
|
110
151
|
timeoutMs;
|
|
111
152
|
onProgress;
|
|
@@ -125,6 +166,14 @@ export class AuthRunner {
|
|
|
125
166
|
log("DEBUG", "Joining the authentication already in flight");
|
|
126
167
|
return this.inFlight;
|
|
127
168
|
}
|
|
169
|
+
// A caller that answered early is gone, but its child can still be
|
|
170
|
+
// running in the background (waiting on the user's phone). Join it
|
|
171
|
+
// instead of spawning a second child, which would only hit the
|
|
172
|
+
// cross-process lock and return "busy".
|
|
173
|
+
if (this.childDone) {
|
|
174
|
+
log("DEBUG", "Joining the background sign-in still running from an earlier call");
|
|
175
|
+
return this.joinBackgroundChild(this.childDone);
|
|
176
|
+
}
|
|
128
177
|
// The latch is released by the flow that owns it, as it settles, so a
|
|
129
178
|
// failed login is never replayed: the tool call after a declined MFA
|
|
130
179
|
// prompt starts a fresh attempt rather than inheriting the stale
|
|
@@ -137,7 +186,55 @@ export class AuthRunner {
|
|
|
137
186
|
this.inFlight = flow;
|
|
138
187
|
return flow;
|
|
139
188
|
}
|
|
140
|
-
/**
|
|
189
|
+
/**
|
|
190
|
+
* Join a background child from an earlier early-answered call instead of
|
|
191
|
+
* spawning a new one. A joiner must not simply await childDone: that
|
|
192
|
+
* blocks for whatever is left of the 5-minute approval window, exactly the
|
|
193
|
+
* problem run() otherwise fixes, since the caller usually retries right
|
|
194
|
+
* after reading "call this tool again" and well before actually approving.
|
|
195
|
+
*
|
|
196
|
+
* If no challenge has been reported yet (the child hasn't reached MFA),
|
|
197
|
+
* wait for one — or for the child to finish on its own. Once a challenge
|
|
198
|
+
* is known, race the child against a short grace window: fast enough for
|
|
199
|
+
* an already-approved login to land, short enough to re-answer with the
|
|
200
|
+
* same challenge rather than block.
|
|
201
|
+
*/
|
|
202
|
+
async joinBackgroundChild(childDone) {
|
|
203
|
+
if (!this.pendingChallenge && this.challengeSignal) {
|
|
204
|
+
await Promise.race([childDone.catch(() => { }), this.challengeSignal]);
|
|
205
|
+
}
|
|
206
|
+
const challenge = this.pendingChallenge;
|
|
207
|
+
if (!challenge)
|
|
208
|
+
return childDone;
|
|
209
|
+
return new Promise((resolve, reject) => {
|
|
210
|
+
let settled = false;
|
|
211
|
+
const graceTimer = setTimeout(() => {
|
|
212
|
+
if (settled)
|
|
213
|
+
return;
|
|
214
|
+
settled = true;
|
|
215
|
+
reject(new AuthProcessError(...mfaPendingFailure(challenge.numberMatch), challenge.numberMatch));
|
|
216
|
+
}, JOIN_GRACE_MS);
|
|
217
|
+
graceTimer.unref?.();
|
|
218
|
+
childDone.then((value) => {
|
|
219
|
+
if (settled)
|
|
220
|
+
return;
|
|
221
|
+
settled = true;
|
|
222
|
+
clearTimeout(graceTimer);
|
|
223
|
+
resolve(value);
|
|
224
|
+
}, (error) => {
|
|
225
|
+
if (settled)
|
|
226
|
+
return;
|
|
227
|
+
settled = true;
|
|
228
|
+
clearTimeout(graceTimer);
|
|
229
|
+
reject(error);
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* One child process, start to finish. The returned promise can settle
|
|
235
|
+
* before the child actually exits (see the stdout handler below); the
|
|
236
|
+
* child's real completion is tracked separately on this.childDone.
|
|
237
|
+
*/
|
|
141
238
|
async spawnAuth() {
|
|
142
239
|
log("INFO", "Auto-launching brightspace-auth...");
|
|
143
240
|
return await new Promise((resolve, reject) => {
|
|
@@ -149,7 +246,14 @@ export class AuthRunner {
|
|
|
149
246
|
detached: process.platform !== "win32",
|
|
150
247
|
});
|
|
151
248
|
let timedOut = false;
|
|
152
|
-
|
|
249
|
+
// Whether the caller-facing promise above (resolve/reject) has
|
|
250
|
+
// settled. Distinct from childFinished: an early MFA answer settles
|
|
251
|
+
// this while the child keeps running.
|
|
252
|
+
let callerSettled = false;
|
|
253
|
+
// Whether the child has actually finished (exited, timed out, or
|
|
254
|
+
// failed to start) and teardown has run. Guards every handler below
|
|
255
|
+
// against double cleanup.
|
|
256
|
+
let childFinished = false;
|
|
153
257
|
let numberMatch;
|
|
154
258
|
let killTimer;
|
|
155
259
|
const kill = (signal) => {
|
|
@@ -182,25 +286,83 @@ export class AuthRunner {
|
|
|
182
286
|
};
|
|
183
287
|
const onExit = () => kill("SIGTERM");
|
|
184
288
|
process.once("exit", onExit);
|
|
185
|
-
const
|
|
186
|
-
if (
|
|
289
|
+
const settleCaller = (error) => {
|
|
290
|
+
if (callerSettled)
|
|
187
291
|
return;
|
|
188
|
-
|
|
292
|
+
callerSettled = true;
|
|
293
|
+
if (error)
|
|
294
|
+
reject(error);
|
|
295
|
+
else
|
|
296
|
+
resolve(true);
|
|
297
|
+
};
|
|
298
|
+
// Tracks the child to its real end, independent of an early caller
|
|
299
|
+
// answer. Exposed as this.childDone so the next run() call can join
|
|
300
|
+
// it. A dummy catch keeps an unapproved background failure from
|
|
301
|
+
// becoming an unhandled rejection when nobody ever joins it; it does
|
|
302
|
+
// not stop a later `await this.childDone` from seeing the rejection.
|
|
303
|
+
let resolveChildDone;
|
|
304
|
+
let rejectChildDone;
|
|
305
|
+
const completion = new Promise((res, rej) => {
|
|
306
|
+
resolveChildDone = res;
|
|
307
|
+
rejectChildDone = rej;
|
|
308
|
+
});
|
|
309
|
+
const trackedCompletion = completion.finally(() => {
|
|
310
|
+
if (this.childDone === trackedCompletion) {
|
|
311
|
+
this.childDone = null;
|
|
312
|
+
this.pendingChallenge = null;
|
|
313
|
+
this.challengeSignal = null;
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
trackedCompletion.catch(() => { });
|
|
317
|
+
this.childDone = trackedCompletion;
|
|
318
|
+
this.pendingChallenge = null;
|
|
319
|
+
let resolveChallengeSignal;
|
|
320
|
+
this.challengeSignal = new Promise((res) => { resolveChallengeSignal = res; });
|
|
321
|
+
// Records the challenge (number or not) and wakes a joiner waiting in
|
|
322
|
+
// joinBackgroundChild. Idempotent on the "already known" question, but
|
|
323
|
+
// a later number still overwrites a numberless pendingChallenge so a
|
|
324
|
+
// fresh joiner sees it — see MFA_PENDING_MARKER's own comment.
|
|
325
|
+
const publishChallenge = (matched) => {
|
|
326
|
+
const firstChallenge = this.pendingChallenge === null;
|
|
327
|
+
if (firstChallenge || matched) {
|
|
328
|
+
this.pendingChallenge = { numberMatch: matched ?? this.pendingChallenge?.numberMatch };
|
|
329
|
+
}
|
|
330
|
+
if (firstChallenge)
|
|
331
|
+
resolveChallengeSignal();
|
|
332
|
+
if (!callerSettled) {
|
|
333
|
+
settleCaller(new AuthProcessError(...mfaPendingFailure(this.pendingChallenge?.numberMatch), this.pendingChallenge?.numberMatch));
|
|
334
|
+
}
|
|
335
|
+
};
|
|
336
|
+
// Runs once the child is actually done. Settles the caller too, if an
|
|
337
|
+
// early answer had not already done so; otherwise this is just the
|
|
338
|
+
// background sign-in finishing, which only childDone's joiner sees.
|
|
339
|
+
const finishChild = (error) => {
|
|
340
|
+
if (childFinished)
|
|
341
|
+
return;
|
|
342
|
+
childFinished = true;
|
|
189
343
|
clearTimeout(timer);
|
|
190
344
|
if (killTimer)
|
|
191
345
|
clearTimeout(killTimer);
|
|
192
346
|
process.off("exit", onExit);
|
|
347
|
+
const answeredEarly = callerSettled;
|
|
348
|
+
settleCaller(error);
|
|
349
|
+
if (answeredEarly) {
|
|
350
|
+
if (error)
|
|
351
|
+
log("WARN", `Background sign-in finished with ${error.kind}: ${error.message}`);
|
|
352
|
+
else
|
|
353
|
+
log("INFO", "Background sign-in completed successfully after an early MFA response");
|
|
354
|
+
}
|
|
193
355
|
if (error)
|
|
194
|
-
|
|
356
|
+
rejectChildDone(error);
|
|
195
357
|
else
|
|
196
|
-
|
|
358
|
+
resolveChildDone(true);
|
|
197
359
|
};
|
|
198
360
|
const timer = setTimeout(() => {
|
|
199
361
|
timedOut = true;
|
|
200
362
|
kill("SIGTERM");
|
|
201
363
|
killTimer = setTimeout(() => {
|
|
202
364
|
kill("SIGKILL");
|
|
203
|
-
|
|
365
|
+
finishChild(new AuthProcessError("timeout", `Authentication timed out. Run ${AUTH_COMMAND} to try again.`));
|
|
204
366
|
}, KILL_GRACE_MS);
|
|
205
367
|
}, this.timeoutMs);
|
|
206
368
|
forwardLines(child.stderr, (line) => {
|
|
@@ -211,31 +373,38 @@ export class AuthRunner {
|
|
|
211
373
|
catch { /* Logging must not interrupt authentication. */ }
|
|
212
374
|
});
|
|
213
375
|
// Piped and drained rather than ignored: a full stdout pipe would
|
|
214
|
-
// block the child mid-login.
|
|
376
|
+
// block the child mid-login. The first marker settles the caller
|
|
377
|
+
// immediately, without killing the child — see the class doc comment.
|
|
215
378
|
forwardLines(child.stdout, (line) => {
|
|
216
|
-
const
|
|
217
|
-
if (
|
|
218
|
-
numberMatch =
|
|
219
|
-
|
|
379
|
+
const numberMarker = MFA_NUMBER_MARKER.exec(line);
|
|
380
|
+
if (numberMarker) {
|
|
381
|
+
numberMatch = numberMarker[1];
|
|
382
|
+
publishChallenge(numberMatch);
|
|
383
|
+
}
|
|
384
|
+
else if (MFA_PENDING_MARKER.test(line)) {
|
|
385
|
+
publishChallenge(undefined);
|
|
386
|
+
}
|
|
387
|
+
else {
|
|
220
388
|
log("DEBUG", line);
|
|
389
|
+
}
|
|
221
390
|
});
|
|
222
391
|
child.on("error", (error) => {
|
|
223
|
-
if (
|
|
392
|
+
if (childFinished)
|
|
224
393
|
return;
|
|
225
394
|
log("ERROR", "Auto-auth process failed", error.message);
|
|
226
395
|
kill("SIGKILL");
|
|
227
|
-
|
|
396
|
+
finishChild(new AuthProcessError("failed", `Could not start authentication. Run ${AUTH_COMMAND} for details.`));
|
|
228
397
|
});
|
|
229
398
|
child.on("close", (code) => {
|
|
230
|
-
if (
|
|
399
|
+
if (childFinished)
|
|
231
400
|
return;
|
|
232
401
|
if (timedOut) {
|
|
233
402
|
kill("SIGKILL");
|
|
234
|
-
|
|
403
|
+
finishChild(new AuthProcessError("timeout", `Authentication timed out. Run ${AUTH_COMMAND} to try again.`));
|
|
235
404
|
}
|
|
236
405
|
else if (code === 0) {
|
|
237
406
|
log("INFO", "Auto-auth completed successfully");
|
|
238
|
-
|
|
407
|
+
finishChild();
|
|
239
408
|
}
|
|
240
409
|
else {
|
|
241
410
|
const failures = {
|
|
@@ -244,13 +413,11 @@ export class AuthRunner {
|
|
|
244
413
|
4: ["unsupported", "This identity provider cannot complete headless authentication. See the authentication logs."],
|
|
245
414
|
5: ["secureStorage", "The native credential store is unavailable or locked. Unlock it and retry."],
|
|
246
415
|
6: ["transport", "Brightspace authentication is temporarily unavailable because of a network or server failure. Your saved session was preserved. Try again later."],
|
|
247
|
-
7: numberMatch
|
|
248
|
-
? ["mfaPending", `Open Microsoft Authenticator and enter ${numberMatch} within 5 minutes, then try again.`]
|
|
249
|
-
: ["mfaPending", "A Microsoft Authenticator approval was not completed in time. Try again."],
|
|
416
|
+
7: mfaPendingFailure(numberMatch),
|
|
250
417
|
};
|
|
251
418
|
const [kind, message] = failures[code ?? -1] ?? ["failed", `Authentication failed. Run ${AUTH_COMMAND} to try again.`];
|
|
252
419
|
kill("SIGKILL");
|
|
253
|
-
|
|
420
|
+
finishChild(new AuthProcessError(kind, message, kind === "mfaPending" ? numberMatch : undefined));
|
|
254
421
|
}
|
|
255
422
|
});
|
|
256
423
|
});
|
|
@@ -39,9 +39,9 @@ export class BrowserAuth {
|
|
|
39
39
|
ssoFlow;
|
|
40
40
|
stateStore;
|
|
41
41
|
cooldown;
|
|
42
|
-
constructor(config,
|
|
42
|
+
constructor(config, options = {}) {
|
|
43
43
|
this.config = config;
|
|
44
|
-
this.ssoFlow = createSSOFlow(config, requestMfaCode);
|
|
44
|
+
this.ssoFlow = createSSOFlow(config, options.requestMfaCode, options.onMfaChallenge);
|
|
45
45
|
this.stateStore = new BrowserStateStore(config.sessionDir);
|
|
46
46
|
this.cooldown = new AuthCooldown(config.sessionDir);
|
|
47
47
|
}
|
|
@@ -178,11 +178,24 @@ function decodeKey(value) {
|
|
|
178
178
|
}
|
|
179
179
|
return Buffer.from(value, "hex");
|
|
180
180
|
}
|
|
181
|
+
function sessionKeyAccount(canonicalDir) {
|
|
182
|
+
return `session-key:${createHash("sha256").update(canonicalDir).digest("hex")}`;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Whether this session directory already holds a native encryption key, which
|
|
186
|
+
* is what separates an upgrade that has not run yet from one that finished.
|
|
187
|
+
* A locked or unavailable store raises instead of answering false, so a
|
|
188
|
+
* caller never mistakes "cannot tell" for "not migrated".
|
|
189
|
+
*/
|
|
190
|
+
export async function hasSessionEncryptionKey(sessionDir, backend = nativeCredentialBackend) {
|
|
191
|
+
const canonicalDir = await fs.realpath(sessionDir);
|
|
192
|
+
return (await backend.getPassword(SERVICE, sessionKeyAccount(canonicalDir))) !== null;
|
|
193
|
+
}
|
|
181
194
|
/** The canonical session directory stays stable when DHCP changes the hostname. */
|
|
182
195
|
export async function getSessionEncryptionKey(sessionDir, backend = nativeCredentialBackend, create = true) {
|
|
183
196
|
await fs.mkdir(sessionDir, { recursive: true, mode: 0o700 });
|
|
184
197
|
const canonicalDir = await fs.realpath(sessionDir);
|
|
185
|
-
const account =
|
|
198
|
+
const account = sessionKeyAccount(canonicalDir);
|
|
186
199
|
const existing = await backend.getPassword(SERVICE, account);
|
|
187
200
|
if (existing !== null) {
|
|
188
201
|
try {
|