brightspace-mcp-server 4.0.1 → 4.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,6 +8,7 @@ import * as fs from "node:fs/promises";
8
8
  import * as path from "node:path";
9
9
  import { spawn } from "node:child_process";
10
10
  import { acquireProcessLock, AuthenticationInProgressError } from "./auth-lock.js";
11
+ import { canonicalUsername } from "./username.js";
11
12
  const SERVICE = "brightspace-mcp-server";
12
13
  const LINUX_GUIDANCE = "Linux requires secret-tool and an unlocked Secret Service. Install libsecret-tools (Debian/Ubuntu) or your distribution's libsecret tools, unlock the desktop keyring, then retry. Temporary kernel key storage is not supported.";
13
14
  export class NativeCredentialStoreError extends Error {
@@ -158,63 +159,87 @@ export const nativeCredentialBackend = {
158
159
  }
159
160
  },
160
161
  };
161
- function passwordAccount(baseUrl, username) {
162
- const identity = JSON.stringify([new URL(baseUrl).origin, username]);
163
- return `password:${createHash("sha256").update(identity).digest("hex")}`;
162
+ /**
163
+ * Where one of an account's secrets lives, canonical key first. Releases up to
164
+ * 4.0.0 hashed the username exactly as given, so that key follows as a legacy
165
+ * one to read from and clean up. The authenticator enrollment uses a separate
166
+ * `totp:` prefix so the seed can never be returned where a password is
167
+ * expected, and the same username rules so one account's two secrets live or
168
+ * die together. A Purdue-fork enrollment saved under the lowercased email is
169
+ * already the canonical key. TOTP idea from ElliotDrel/brightspace-mcp-server
170
+ * (branch codex/purdue-totp).
171
+ */
172
+ function accountKeys(prefix, baseUrl, username) {
173
+ const origin = new URL(baseUrl).origin;
174
+ const key = (name) => `${prefix}:${createHash("sha256").update(JSON.stringify([origin, name])).digest("hex")}`;
175
+ const canonical = key(canonicalUsername(username));
176
+ const exact = key(username);
177
+ return exact === canonical ? [canonical] : [canonical, exact];
164
178
  }
165
179
  /**
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).
180
+ * Read a secret under its canonical key, falling back to a legacy key. A
181
+ * legacy secret is copied to the canonical key and removed only once the copy
182
+ * reads back intact; a store that refuses any step keeps the legacy entry,
183
+ * and the secret is returned either way.
171
184
  */
172
- function totpAccount(baseUrl, username) {
173
- const identity = JSON.stringify([new URL(baseUrl).origin, username]);
174
- return `totp:${createHash("sha256").update(identity).digest("hex")}`;
185
+ async function getSecret(prefix, baseUrl, username, backend) {
186
+ const [canonical, ...legacy] = accountKeys(prefix, baseUrl, username);
187
+ const current = await backend.getPassword(SERVICE, canonical);
188
+ if (current !== null)
189
+ return current;
190
+ for (const account of legacy) {
191
+ const value = await backend.getPassword(SERVICE, account);
192
+ if (value === null)
193
+ continue;
194
+ try {
195
+ await backend.setPassword(SERVICE, canonical, value);
196
+ if (await backend.getPassword(SERVICE, canonical) === value)
197
+ await backend.deletePassword(SERVICE, account);
198
+ }
199
+ catch {
200
+ // Migration is best effort; the legacy entry still answers next time.
201
+ }
202
+ return value;
203
+ }
204
+ return null;
205
+ }
206
+ async function setSecret(prefix, baseUrl, username, value, backend, unverified) {
207
+ const [account] = accountKeys(prefix, baseUrl, username);
208
+ await backend.setPassword(SERVICE, account, value);
209
+ if (await backend.getPassword(SERVICE, account) !== value)
210
+ throw new NativeCredentialStoreError(unverified);
211
+ }
212
+ /** Remove the secret under every key it may sit under. Safe when none was saved. */
213
+ async function deleteSecret(prefix, baseUrl, username, backend, kept) {
214
+ for (const account of accountKeys(prefix, baseUrl, username)) {
215
+ // Some native stores raise on deleting an entry that does not exist.
216
+ if (await backend.getPassword(SERVICE, account) === null)
217
+ continue;
218
+ await backend.deletePassword(SERVICE, account);
219
+ if (await backend.getPassword(SERVICE, account) !== null)
220
+ throw new NativeCredentialStoreError(kept);
221
+ }
175
222
  }
176
223
  /** The account's saved authenticator enrollment, or null when none was saved. */
177
224
  export async function getStoredTotpUri(baseUrl, username, backend = nativeCredentialBackend) {
178
- return backend.getPassword(SERVICE, totpAccount(baseUrl, username));
225
+ return getSecret("totp", baseUrl, username, backend);
179
226
  }
180
227
  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
- }
228
+ await setSecret("totp", baseUrl, username, uri, backend, "The authenticator enrollment could not be verified in the native credential store. Existing configuration was preserved.");
186
229
  }
187
230
  /** Remove an account's saved authenticator enrollment. Safe when none was saved. */
188
231
  export async function deleteStoredTotpUri(baseUrl, username, backend = nativeCredentialBackend) {
189
- const account = totpAccount(baseUrl, username);
190
- // Some native stores raise on deleting an entry that does not exist.
191
- if (await backend.getPassword(SERVICE, account) === null)
192
- return;
193
- await backend.deletePassword(SERVICE, account);
194
- if (await backend.getPassword(SERVICE, account) !== null) {
195
- throw new NativeCredentialStoreError("The saved authenticator enrollment could not be removed from the native credential store.");
196
- }
232
+ await deleteSecret("totp", baseUrl, username, backend, "The saved authenticator enrollment could not be removed from the native credential store.");
197
233
  }
198
234
  export async function getStoredPassword(baseUrl, username, backend = nativeCredentialBackend) {
199
- return backend.getPassword(SERVICE, passwordAccount(baseUrl, username));
235
+ return getSecret("password", baseUrl, username, backend);
200
236
  }
201
237
  export async function setStoredPassword(baseUrl, username, password, backend = nativeCredentialBackend) {
202
- const account = passwordAccount(baseUrl, username);
203
- await backend.setPassword(SERVICE, account, password);
204
- if (await backend.getPassword(SERVICE, account) !== password) {
205
- throw new NativeCredentialStoreError("The saved password could not be verified in the native credential store. Existing configuration was preserved.");
206
- }
238
+ await setSecret("password", baseUrl, username, password, backend, "The saved password could not be verified in the native credential store. Existing configuration was preserved.");
207
239
  }
208
240
  /** Remove an account's saved password, so passwordless sign-in leaves none behind. */
209
241
  export async function deleteStoredPassword(baseUrl, username, backend = nativeCredentialBackend) {
210
- const account = passwordAccount(baseUrl, username);
211
- // Some native stores raise on deleting an entry that does not exist.
212
- if (await backend.getPassword(SERVICE, account) === null)
213
- return;
214
- await backend.deletePassword(SERVICE, account);
215
- if (await backend.getPassword(SERVICE, account) !== null) {
216
- throw new NativeCredentialStoreError("The saved password could not be removed from the native credential store. Existing configuration was preserved.");
217
- }
242
+ await deleteSecret("password", baseUrl, username, backend, "The saved password could not be removed from the native credential store. Existing configuration was preserved.");
218
243
  }
219
244
  function decodeKey(value) {
220
245
  if (!/^[a-f0-9]{64}$/i.test(value)) {
@@ -0,0 +1,15 @@
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
+ /**
7
+ * The form of a username that names its account. An email address (a
8
+ * Microsoft Entra or Google sign-in) is case-insensitive, so it is lowercased;
9
+ * a plain username keeps its case, because a school may treat it as
10
+ * case-sensitive. Surrounding whitespace is never part of anyone's name.
11
+ */
12
+ export function canonicalUsername(username) {
13
+ const trimmed = username.trim();
14
+ return trimmed.includes("@") ? trimmed.toLowerCase() : trimmed;
15
+ }
@@ -5,6 +5,7 @@
5
5
  *
6
6
  * The settings setup saves, shared by the wizard, prepare and sign-in.
7
7
  */
8
+ import { canonicalUsername } from "../auth/username.js";
8
9
  import { configStoreExists, loadConfigStore } from "../utils/config-store.js";
9
10
  // ── URL validation ─────────────────────────────────────────────────
10
11
  export function normalizeUrl(input) {
@@ -50,6 +51,16 @@ export function sameSchool(stored, chosen) {
50
51
  return false;
51
52
  }
52
53
  }
54
+ /**
55
+ * The username to save: the typed one in canonical form, unless it names the
56
+ * account already saved. That one keeps its saved spelling, because releases
57
+ * up to 4.0.0 keyed its password, enrollment and session directory on the
58
+ * exact case, and only that spelling still derives those legacy keys.
59
+ */
60
+ export function savedUsername(saved, typed) {
61
+ const canonical = canonicalUsername(typed);
62
+ return saved !== undefined && canonicalUsername(saved) === canonical ? saved : canonical;
63
+ }
53
64
  /**
54
65
  * Merge the wizard's answers over the settings already saved.
55
66
  *
@@ -68,7 +79,7 @@ export function buildConfigToSave(existing, answers) {
68
79
  const config = {
69
80
  ...carried,
70
81
  baseUrl: answers.baseUrl,
71
- username: answers.username,
82
+ username: savedUsername(carried?.username, answers.username),
72
83
  // Always the freshly typed one: a carried v1 plaintext password would
73
84
  // otherwise be the value written to the native store.
74
85
  password: answers.password,
@@ -180,7 +180,7 @@ export async function runPrepare(args, overrides = {}) {
180
180
  delete config.password;
181
181
  delete config.totpUri;
182
182
  const notes = [];
183
- if (carried?.username && args.username && carried.username !== args.username) {
183
+ if (carried?.username && args.username && carried.username !== config.username) {
184
184
  notes.push(`was ${carried.username}; sign-in starts fresh for the new account`);
185
185
  }
186
186
  if (!username)
@@ -214,7 +214,7 @@ export async function runPrepare(args, overrides = {}) {
214
214
  }
215
215
  if (username) {
216
216
  try {
217
- const token = await deps.getSessionToken(baseUrl, sessionDirFor(deps.env, config, baseUrl, username));
217
+ const token = await deps.getSessionToken(baseUrl, sessionDirFor(deps.env, config, baseUrl, config.username));
218
218
  signedIn = Boolean(token);
219
219
  }
220
220
  catch {
package/build/setup.js CHANGED
@@ -21,7 +21,7 @@ import { askShortcutQuestion } from "./utils/two-factor-shortcuts.js";
21
21
  import { DOCTOR_COMMAND, prepareCommand, signInCommand } from "./utils/commands.js";
22
22
  import { serverCommand } from "./utils/mcp-client-cli.js";
23
23
  import { parseSetupArgs, SETUP_USAGE } from "./setup/args.js";
24
- import { buildConfigToSave, isValidUrl, normalizeUrl, readExistingConfig, sameSchool } from "./setup/config.js";
24
+ import { buildConfigToSave, isValidUrl, normalizeUrl, readExistingConfig, sameSchool, savedUsername } from "./setup/config.js";
25
25
  import { detectMcpClients, isChatGPTInstalled } from "./setup/clients.js";
26
26
  import { presetForBaseUrl } from "./setup/presets.js";
27
27
  import { formatPrepare, runPrepare } from "./setup/prepare.js";
@@ -254,7 +254,9 @@ async function interactive(args) {
254
254
  }
255
255
  let preset = args.preset ?? (baseUrl ? presetForBaseUrl(baseUrl)?.preset : undefined);
256
256
  const carried = baseUrl && saved && sameSchool(saved.baseUrl, baseUrl) ? saved : null;
257
- const knownUsername = args.username ?? (mode === "sign-in" ? carried?.username : undefined);
257
+ const knownUsername = args.username !== undefined
258
+ ? savedUsername(carried?.username, args.username)
259
+ : mode === "sign-in" ? carried?.username : undefined;
258
260
  let passwordSaved = false;
259
261
  if (mode === "sign-in" && baseUrl && knownUsername) {
260
262
  try {
@@ -341,6 +343,8 @@ async function interactive(args) {
341
343
  else {
342
344
  console.log(dim(` Username: ${username}`));
343
345
  }
346
+ // The saved spelling of the same account still finds what 4.0.0 saved.
347
+ username = savedUsername(saved && sameSchool(saved.baseUrl, baseUrl) ? saved.username : undefined, username);
344
348
  console.log("");
345
349
  // ── Step 3: Password (hidden) ────────────────────────────────────
346
350
  let keepPassword = false;
@@ -3,6 +3,7 @@
3
3
  * Copyright (c) 2026 Rohan Muppa. All rights reserved.
4
4
  * Licensed under MIT — see LICENSE file for details.
5
5
  */
6
+ import * as fs from "node:fs";
6
7
  import * as path from "node:path";
7
8
  import * as os from "node:os";
8
9
  import { createHash } from "node:crypto";
@@ -10,6 +11,7 @@ import dotenv from "dotenv";
10
11
  import { configStoreExists, getConfigStorePath, loadConfigStore } from "./config-store.js";
11
12
  import { resolveStoredPassword } from "./secure-config.js";
12
13
  import { getStoredTotpUri } from "../auth/credential-store.js";
14
+ import { canonicalUsername } from "../auth/username.js";
13
15
  import { normalizeTotpEnrollment } from "../auth/totp.js";
14
16
  import { migrateLegacyState } from "../auth/legacy-state.js";
15
17
  /**
@@ -165,8 +167,14 @@ export async function loadConfig() {
165
167
  export function accountSessionDirectory(root, baseUrl, username) {
166
168
  if (!username)
167
169
  return root;
168
- const account = createHash("sha256").update(JSON.stringify([new URL(baseUrl).origin, username])).digest("hex");
169
- return path.join(root, "accounts", account);
170
+ const origin = new URL(baseUrl).origin;
171
+ const directory = (name) => path.join(root, "accounts", createHash("sha256").update(JSON.stringify([origin, name])).digest("hex"));
172
+ const canonical = directory(canonicalUsername(username));
173
+ // Releases up to 4.0.0 named the directory after the username exactly as
174
+ // given. Its encryption key is tied to that path, so a session already
175
+ // saved there keeps being used rather than stranded.
176
+ const legacy = directory(username);
177
+ return legacy !== canonical && !fs.existsSync(canonical) && fs.existsSync(legacy) ? legacy : canonical;
170
178
  }
171
179
  /**
172
180
  * A token lifetime must be a whole, positive number of seconds. NaN, zero, a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brightspace-mcp-server",
3
- "version": "4.0.1",
3
+ "version": "4.0.3",
4
4
  "mcpName": "io.github.RohanMuppa/brightspace",
5
5
  "description": "MCP server for Brightspace (D2L). Check grades, due dates, assignments, announcements, syllabus, rosters and more via Claude, ChatGPT, Cursor, Windsurf, or any MCP client.",
6
6
  "type": "module",