brightspace-d2l-mcp 1.0.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/LICENSE +24 -0
- package/README.md +269 -0
- package/build/api/cache.js +49 -0
- package/build/api/client.js +345 -0
- package/build/api/errors.js +48 -0
- package/build/api/index.js +12 -0
- package/build/api/paginate.js +70 -0
- package/build/api/rate-limiter.js +52 -0
- package/build/api/retry.js +39 -0
- package/build/api/types.js +11 -0
- package/build/api/version-discovery.js +43 -0
- package/build/auth/authenticator.js +146 -0
- package/build/auth/browser.js +82 -0
- package/build/auth/index.js +6 -0
- package/build/auth/login.js +227 -0
- package/build/auth/session-store.js +46 -0
- package/build/auth/token-manager.js +150 -0
- package/build/auth/token-mint.js +75 -0
- package/build/cli/clients.js +344 -0
- package/build/cli/main.js +162 -0
- package/build/cli/school.js +74 -0
- package/build/cli/setup.js +189 -0
- package/build/cli/ui.js +39 -0
- package/build/index.js +13 -0
- package/build/server.js +175 -0
- package/build/tools/download-file.js +167 -0
- package/build/tools/get-announcements.js +116 -0
- package/build/tools/get-assignment-files.js +159 -0
- package/build/tools/get-assignments.js +343 -0
- package/build/tools/get-classlist-emails.js +38 -0
- package/build/tools/get-course-content.js +175 -0
- package/build/tools/get-discussions.js +192 -0
- package/build/tools/get-my-courses.js +42 -0
- package/build/tools/get-my-grades.js +93 -0
- package/build/tools/get-roster.js +99 -0
- package/build/tools/get-syllabus.js +154 -0
- package/build/tools/get-todo.js +61 -0
- package/build/tools/get-upcoming-due-dates.js +138 -0
- package/build/tools/index.js +18 -0
- package/build/tools/schemas.js +91 -0
- package/build/tools/submission-status.js +71 -0
- package/build/tools/tool-helpers.js +62 -0
- package/build/types/index.js +2 -0
- package/build/utils/atomic-write.js +66 -0
- package/build/utils/config.js +60 -0
- package/build/utils/course-filter.js +24 -0
- package/build/utils/deep-links.js +13 -0
- package/build/utils/download-errors.js +14 -0
- package/build/utils/download-helpers.js +73 -0
- package/build/utils/errors.js +31 -0
- package/build/utils/file-validator.js +121 -0
- package/build/utils/html-converter.js +20 -0
- package/build/utils/logger.js +69 -0
- package/build/utils/paths.js +15 -0
- package/build/utils/pdf-extractor.js +18 -0
- package/build/utils/version.js +18 -0
- package/build/utils/zip-extract.js +139 -0
- package/package.json +104 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { loginWithBrowser, LoginError } from "./login.js";
|
|
4
|
+
import { NoBrowserError, ProfileInUseError } from "./browser.js";
|
|
5
|
+
import { loginLockPath, LOGIN_COMMAND, SETUP_COMMAND } from "../utils/paths.js";
|
|
6
|
+
import { log } from "../utils/logger.js";
|
|
7
|
+
export class AuthRequiredError extends Error {
|
|
8
|
+
kind;
|
|
9
|
+
constructor(kind, message, options) {
|
|
10
|
+
super(message, options);
|
|
11
|
+
this.kind = kind;
|
|
12
|
+
this.name = "AuthRequiredError";
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
const LOCK_STALE_MS = 12 * 60_000;
|
|
16
|
+
const COOLDOWN_MS = 2 * 60_000;
|
|
17
|
+
export class Authenticator {
|
|
18
|
+
options;
|
|
19
|
+
inFlight = null;
|
|
20
|
+
cooldownUntil = 0;
|
|
21
|
+
constructor(options) {
|
|
22
|
+
this.options = options;
|
|
23
|
+
}
|
|
24
|
+
run() {
|
|
25
|
+
this.inFlight ??= this.authenticate().finally(() => { this.inFlight = null; });
|
|
26
|
+
return this.inFlight;
|
|
27
|
+
}
|
|
28
|
+
async authenticate() {
|
|
29
|
+
const { baseUrl, tokenManager } = this.options;
|
|
30
|
+
if (!baseUrl) {
|
|
31
|
+
throw new AuthRequiredError("notConfigured", `Brightspace is not set up yet. Run \`${SETUP_COMMAND}\` in a terminal.`);
|
|
32
|
+
}
|
|
33
|
+
if (Date.now() < this.cooldownUntil) {
|
|
34
|
+
throw new AuthRequiredError("cancelled", `Sign-in was cancelled recently. Run \`${LOGIN_COMMAND}\` in a terminal, or ask again in a couple of minutes.`);
|
|
35
|
+
}
|
|
36
|
+
const lock = await acquireLock();
|
|
37
|
+
if (!lock) {
|
|
38
|
+
// Another process signed in (or is signing in) for us; use what it saved.
|
|
39
|
+
return (await tokenManager.getToken()) !== null;
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
// Another process may have finished just before we got the lock.
|
|
43
|
+
if (await tokenManager.getToken())
|
|
44
|
+
return true;
|
|
45
|
+
return await this.signIn();
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
lock.release();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
async signIn() {
|
|
52
|
+
const { baseUrl, tokenTtl, autoLogin, tokenManager } = this.options;
|
|
53
|
+
const notify = this.options.notify ?? (() => { });
|
|
54
|
+
try {
|
|
55
|
+
log("INFO", "Trying silent sign-in with the saved browser profile");
|
|
56
|
+
const { token } = await loginWithBrowser({ baseUrl, mode: "silent", tokenTtlSeconds: tokenTtl });
|
|
57
|
+
await tokenManager.setToken(token);
|
|
58
|
+
log("INFO", "Silent sign-in succeeded");
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
if (!(error instanceof LoginError && error.kind === "needsInteraction"))
|
|
63
|
+
throw toAuthError(error);
|
|
64
|
+
}
|
|
65
|
+
if (!autoLogin) {
|
|
66
|
+
throw new AuthRequiredError("loginRequired", `Your Brightspace session expired. Run \`${LOGIN_COMMAND}\` in a terminal to sign in again.`);
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
notify("Your Brightspace session expired. A browser window is opening so you can sign in again.");
|
|
70
|
+
const { token, displayName } = await loginWithBrowser({ baseUrl, mode: "interactive", tokenTtlSeconds: tokenTtl, onStatus: notify });
|
|
71
|
+
await tokenManager.setToken(token);
|
|
72
|
+
log("INFO", `Interactive sign-in succeeded${displayName ? ` for ${displayName}` : ""}`);
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
if (error instanceof LoginError && (error.kind === "cancelled" || error.kind === "timeout")) {
|
|
77
|
+
this.cooldownUntil = Date.now() + COOLDOWN_MS;
|
|
78
|
+
}
|
|
79
|
+
throw toAuthError(error);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function toAuthError(error) {
|
|
84
|
+
if (error instanceof AuthRequiredError)
|
|
85
|
+
return error;
|
|
86
|
+
if (error instanceof NoBrowserError)
|
|
87
|
+
return new AuthRequiredError("noBrowser", error.message, { cause: error });
|
|
88
|
+
if (error instanceof ProfileInUseError)
|
|
89
|
+
return new AuthRequiredError("busy", error.message, { cause: error });
|
|
90
|
+
if (error instanceof LoginError) {
|
|
91
|
+
const guidance = {
|
|
92
|
+
cancelled: ["cancelled", "The sign-in window was closed before signing in finished."],
|
|
93
|
+
timeout: ["timeout", "Sign-in was not completed within 10 minutes."],
|
|
94
|
+
needsInteraction: ["loginRequired", "Your Brightspace session expired."],
|
|
95
|
+
network: ["network", "Brightspace could not be reached. Check your connection and try again."],
|
|
96
|
+
failed: ["failed", "Signing in to Brightspace did not complete."],
|
|
97
|
+
};
|
|
98
|
+
const [kind, text] = guidance[error.kind];
|
|
99
|
+
return new AuthRequiredError(kind, `${text} Run \`${LOGIN_COMMAND}\` in a terminal to sign in.`, { cause: error });
|
|
100
|
+
}
|
|
101
|
+
log("ERROR", "Unexpected sign-in failure", error);
|
|
102
|
+
return new AuthRequiredError("failed", `Signing in to Brightspace did not complete. Run \`${LOGIN_COMMAND}\` in a terminal to sign in.`, { cause: error });
|
|
103
|
+
}
|
|
104
|
+
async function acquireLock() {
|
|
105
|
+
const file = loginLockPath();
|
|
106
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
107
|
+
let waited = false;
|
|
108
|
+
const deadline = Date.now() + LOCK_STALE_MS;
|
|
109
|
+
while (Date.now() < deadline) {
|
|
110
|
+
try {
|
|
111
|
+
const fd = fs.openSync(file, "wx", 0o600);
|
|
112
|
+
fs.writeSync(fd, JSON.stringify({ pid: process.pid, startedAt: Date.now() }));
|
|
113
|
+
fs.closeSync(fd);
|
|
114
|
+
if (waited) {
|
|
115
|
+
fs.rmSync(file, { force: true });
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
return { release: () => fs.rmSync(file, { force: true }) };
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
if (error.code !== "EEXIST")
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
124
|
+
if (lockIsStale(file)) {
|
|
125
|
+
fs.rmSync(file, { force: true });
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
waited = true;
|
|
129
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
130
|
+
}
|
|
131
|
+
throw new AuthRequiredError("busy", "Another sign-in is still in progress. Finish it, then try again.");
|
|
132
|
+
}
|
|
133
|
+
function lockIsStale(file) {
|
|
134
|
+
try {
|
|
135
|
+
const { pid, startedAt } = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
136
|
+
if (Date.now() - startedAt > LOCK_STALE_MS)
|
|
137
|
+
return true;
|
|
138
|
+
process.kill(pid, 0);
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
// ESRCH: the owner died. Unreadable: a crash mid-write.
|
|
143
|
+
return error?.code !== "EPERM";
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
//# sourceMappingURL=authenticator.js.map
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import { browserProfileDir } from "../utils/paths.js";
|
|
3
|
+
import { log } from "../utils/logger.js";
|
|
4
|
+
export class NoBrowserError extends Error {
|
|
5
|
+
constructor(detail) {
|
|
6
|
+
super("No supported browser was found. Install Google Chrome or Microsoft Edge, " +
|
|
7
|
+
"or run `npx playwright install chromium`, then try again." + (detail ? ` (${detail})` : ""));
|
|
8
|
+
this.name = "NoBrowserError";
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export class ProfileInUseError extends Error {
|
|
12
|
+
constructor() {
|
|
13
|
+
super("The sign-in browser is already open. Finish signing in there, or close it and try again.");
|
|
14
|
+
this.name = "ProfileInUseError";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
function candidates() {
|
|
18
|
+
const custom = process.env.BRIGHTSPACE_BROWSER_PATH;
|
|
19
|
+
if (custom)
|
|
20
|
+
return [{ label: custom, executablePath: custom }];
|
|
21
|
+
return [
|
|
22
|
+
{ label: "Google Chrome", channel: "chrome" },
|
|
23
|
+
{ label: "Microsoft Edge", channel: "msedge" },
|
|
24
|
+
{ label: "Chromium (Playwright)" },
|
|
25
|
+
];
|
|
26
|
+
}
|
|
27
|
+
let preferred = null;
|
|
28
|
+
function isMissingExecutable(error) {
|
|
29
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
30
|
+
return /Executable doesn't exist|is not found at|not installed|Chromium distribution .* is not found|Failed to launch/i.test(message)
|
|
31
|
+
&& !isProfileInUse(error);
|
|
32
|
+
}
|
|
33
|
+
function isProfileInUse(error) {
|
|
34
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
35
|
+
return /ProcessSingleton|profile (?:appears to be )?in use|user data directory is already in use|SingletonLock/i.test(message);
|
|
36
|
+
}
|
|
37
|
+
export async function launchBrowser(options) {
|
|
38
|
+
const { chromium } = await import("playwright-core");
|
|
39
|
+
const profile = browserProfileDir();
|
|
40
|
+
fs.mkdirSync(profile, { recursive: true, mode: 0o700 });
|
|
41
|
+
const order = preferred ? [preferred, ...candidates().filter((c) => c.label !== preferred.label)] : candidates();
|
|
42
|
+
const failures = [];
|
|
43
|
+
for (const candidate of order) {
|
|
44
|
+
try {
|
|
45
|
+
const context = await chromium.launchPersistentContext(profile, {
|
|
46
|
+
headless: options.headless,
|
|
47
|
+
channel: candidate.channel,
|
|
48
|
+
executablePath: candidate.executablePath,
|
|
49
|
+
viewport: options.headless ? { width: 1280, height: 800 } : null,
|
|
50
|
+
ignoreDefaultArgs: ["--enable-automation"],
|
|
51
|
+
args: ["--disable-blink-features=AutomationControlled", "--no-first-run", "--no-default-browser-check"],
|
|
52
|
+
timeout: 60_000,
|
|
53
|
+
});
|
|
54
|
+
preferred = candidate;
|
|
55
|
+
log("DEBUG", `Launched ${candidate.label} (${options.headless ? "hidden" : "visible"})`);
|
|
56
|
+
if (options.headless)
|
|
57
|
+
await hideHeadlessUserAgent(context);
|
|
58
|
+
return context;
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
if (isProfileInUse(error))
|
|
62
|
+
throw new ProfileInUseError();
|
|
63
|
+
if (!isMissingExecutable(error))
|
|
64
|
+
throw error;
|
|
65
|
+
failures.push(candidate.label);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
throw new NoBrowserError(`tried ${failures.join(", ")}`);
|
|
69
|
+
}
|
|
70
|
+
async function hideHeadlessUserAgent(context) {
|
|
71
|
+
try {
|
|
72
|
+
const page = context.pages()[0] ?? (await context.newPage());
|
|
73
|
+
const ua = await page.evaluate(() => navigator.userAgent);
|
|
74
|
+
if (ua.includes("HeadlessChrome")) {
|
|
75
|
+
await context.setExtraHTTPHeaders({ "user-agent": ua.replace("HeadlessChrome", "Chrome") });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
log("DEBUG", "Could not adjust headless user agent", error);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
//# sourceMappingURL=browser.js.map
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { TokenManager } from "./token-manager.js";
|
|
2
|
+
export { SessionStore } from "./session-store.js";
|
|
3
|
+
export { Authenticator, AuthRequiredError } from "./authenticator.js";
|
|
4
|
+
export { loginWithBrowser, LoginError } from "./login.js";
|
|
5
|
+
export { NoBrowserError } from "./browser.js";
|
|
6
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { writeFileAtomic } from "../utils/atomic-write.js";
|
|
4
|
+
import { savedCookiesPath } from "../utils/paths.js";
|
|
5
|
+
import { readStoredConfig, writeStoredConfig } from "../utils/config.js";
|
|
6
|
+
import { launchBrowser } from "./browser.js";
|
|
7
|
+
import { mintAccessToken } from "./token-mint.js";
|
|
8
|
+
import { log } from "../utils/logger.js";
|
|
9
|
+
export class LoginError extends Error {
|
|
10
|
+
kind;
|
|
11
|
+
constructor(kind, message, options) {
|
|
12
|
+
super(message, options);
|
|
13
|
+
this.kind = kind;
|
|
14
|
+
this.name = "LoginError";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
const POLL_MS = 1000;
|
|
18
|
+
export async function loginWithBrowser(options) {
|
|
19
|
+
const origin = new URL(options.baseUrl).origin;
|
|
20
|
+
const interactive = options.mode === "interactive";
|
|
21
|
+
const timeoutMs = options.timeoutMs ?? (interactive ? 10 * 60_000 : 45_000);
|
|
22
|
+
const status = options.onStatus ?? (() => { });
|
|
23
|
+
const context = await launchBrowser({ headless: !interactive });
|
|
24
|
+
let closedByUser = false;
|
|
25
|
+
context.on("close", () => { closedByUser = true; });
|
|
26
|
+
// Remember which single sign-on entry the person used (an SSO button, a campus link),
|
|
27
|
+
// so silent sign-in can follow it without anyone clicking.
|
|
28
|
+
let ssoEntryUrl = null;
|
|
29
|
+
context.on("request", (request) => {
|
|
30
|
+
const url = safeUrl(request.url());
|
|
31
|
+
if (url && url.origin === origin && /^\/d2l\/lp\/auth\/saml\/(login|initiate-login)/i.test(url.pathname) && request.isNavigationRequest()) {
|
|
32
|
+
ssoEntryUrl = `${url.origin}${url.pathname}${url.search}`;
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
try {
|
|
36
|
+
await restoreCookies(context);
|
|
37
|
+
const page = context.pages()[0] ?? (await context.newPage());
|
|
38
|
+
if (interactive) {
|
|
39
|
+
await page.bringToFront().catch(() => { });
|
|
40
|
+
status("A browser window opened. Sign in to Brightspace there, the way you normally do.");
|
|
41
|
+
}
|
|
42
|
+
await page.goto(`${origin}/d2l/home`, { waitUntil: "domcontentloaded", timeout: 60_000 }).catch((error) => {
|
|
43
|
+
// Single sign-on redirects can outlive the navigation timeout; keep watching.
|
|
44
|
+
log("DEBUG", "Initial navigation did not settle", error);
|
|
45
|
+
});
|
|
46
|
+
const stored = readStoredConfig();
|
|
47
|
+
const user = await waitForSession(context, origin, {
|
|
48
|
+
deadline: Date.now() + timeoutMs,
|
|
49
|
+
interactive,
|
|
50
|
+
isClosed: () => closedByUser,
|
|
51
|
+
ssoEntryUrl: stored.baseUrl === origin ? stored.ssoEntryUrl : undefined,
|
|
52
|
+
});
|
|
53
|
+
status(user.displayName ? `Signed in as ${user.displayName}.` : "Signed in.");
|
|
54
|
+
const token = await captureToken(context, activePage(context) ?? page, origin, options.tokenTtlSeconds ?? 3600);
|
|
55
|
+
await saveCookies(context);
|
|
56
|
+
if (interactive && ssoEntryUrl && stored.baseUrl === origin && stored.ssoEntryUrl !== ssoEntryUrl) {
|
|
57
|
+
writeStoredConfig({ ssoEntryUrl });
|
|
58
|
+
}
|
|
59
|
+
return { token, displayName: user.displayName };
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
if (error instanceof LoginError)
|
|
63
|
+
throw error;
|
|
64
|
+
if (closedByUser)
|
|
65
|
+
throw new LoginError("cancelled", "The sign-in window was closed before signing in finished.");
|
|
66
|
+
throw new LoginError("failed", `Sign-in failed: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
67
|
+
}
|
|
68
|
+
finally {
|
|
69
|
+
if (!closedByUser)
|
|
70
|
+
await context.close().catch(() => { });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
async function waitForSession(context, origin, watch) {
|
|
74
|
+
let offOriginSince = null;
|
|
75
|
+
let followedSsoEntry = false;
|
|
76
|
+
while (Date.now() < watch.deadline) {
|
|
77
|
+
if (watch.isClosed()) {
|
|
78
|
+
throw new LoginError("cancelled", "The sign-in window was closed before signing in finished.");
|
|
79
|
+
}
|
|
80
|
+
const page = activePage(context);
|
|
81
|
+
const url = safeUrl(page?.url());
|
|
82
|
+
if (url && url.origin === origin && url.pathname.startsWith("/d2l/") && !url.pathname.startsWith("/d2l/login")) {
|
|
83
|
+
const user = await whoami(context, origin);
|
|
84
|
+
if (user)
|
|
85
|
+
return user;
|
|
86
|
+
}
|
|
87
|
+
if (!watch.interactive) {
|
|
88
|
+
// A D2L login page that waits for a button click: follow the entry the person used last time.
|
|
89
|
+
if (page && url?.origin === origin && url.pathname.startsWith("/d2l/login") && watch.ssoEntryUrl && !followedSsoEntry) {
|
|
90
|
+
followedSsoEntry = true;
|
|
91
|
+
offOriginSince = null;
|
|
92
|
+
log("DEBUG", "Following the saved single sign-on entry");
|
|
93
|
+
await page.goto(watch.ssoEntryUrl, { waitUntil: "domcontentloaded", timeout: 30_000 }).catch(() => { });
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
// Silent mode: parked on an identity provider or the D2L login page means a person is needed.
|
|
97
|
+
const onLoginPage = !url || url.origin !== origin || url.pathname.startsWith("/d2l/login");
|
|
98
|
+
offOriginSince = onLoginPage ? offOriginSince ?? Date.now() : null;
|
|
99
|
+
if (offOriginSince && Date.now() - offOriginSince > 12_000) {
|
|
100
|
+
throw new LoginError("needsInteraction", "The saved sign-in has expired; a person needs to sign in.");
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
await sleep(POLL_MS);
|
|
104
|
+
}
|
|
105
|
+
throw new LoginError(watch.interactive ? "timeout" : "needsInteraction", watch.interactive ? "Sign-in was not completed in time." : "Silent sign-in did not finish.");
|
|
106
|
+
}
|
|
107
|
+
function activePage(context) {
|
|
108
|
+
const pages = context.pages().filter((p) => !p.isClosed());
|
|
109
|
+
const onBrightspace = pages.filter((p) => p.url().includes("/d2l/"));
|
|
110
|
+
return onBrightspace.at(-1) ?? pages.at(-1);
|
|
111
|
+
}
|
|
112
|
+
async function whoami(context, origin) {
|
|
113
|
+
try {
|
|
114
|
+
const response = await context.request.get(`${origin}/d2l/api/lp/1.0/users/whoami`, { timeout: 10_000, failOnStatusCode: false });
|
|
115
|
+
if (!response.ok() || !(response.headers()["content-type"] ?? "").includes("json"))
|
|
116
|
+
return null;
|
|
117
|
+
const body = (await response.json());
|
|
118
|
+
if (body?.Identifier === undefined || body.Identifier === null || body.Identifier === "")
|
|
119
|
+
return null;
|
|
120
|
+
const name = [body.FirstName, body.LastName].filter(Boolean).join(" ").trim();
|
|
121
|
+
return { displayName: name || null };
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
log("DEBUG", "whoami check failed", error);
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
async function captureToken(context, page, origin, ttlSeconds) {
|
|
129
|
+
if (!page.url().startsWith(`${origin}/d2l/home`)) {
|
|
130
|
+
await page.goto(`${origin}/d2l/home`, { waitUntil: "domcontentloaded", timeout: 30_000 }).catch(() => { });
|
|
131
|
+
}
|
|
132
|
+
const cookies = await context.cookies(origin);
|
|
133
|
+
const pick = (name) => cookies.find((c) => c.name === name);
|
|
134
|
+
const session = pick("d2lSessionVal");
|
|
135
|
+
const secure = pick("d2lSecureSessionVal");
|
|
136
|
+
const cookieHeader = session && secure ? `d2lSessionVal=${session.value}; d2lSecureSessionVal=${secure.value}` : undefined;
|
|
137
|
+
const csrfToken = (await readXsrfToken(page)) ?? undefined;
|
|
138
|
+
const now = Date.now();
|
|
139
|
+
const build = (accessToken) => ({
|
|
140
|
+
accessToken,
|
|
141
|
+
tenantOrigin: origin,
|
|
142
|
+
capturedAt: now,
|
|
143
|
+
expiresAt: now + ttlSeconds * 1000,
|
|
144
|
+
source: "browser",
|
|
145
|
+
cookieHeader,
|
|
146
|
+
csrfToken,
|
|
147
|
+
});
|
|
148
|
+
if (cookieHeader && csrfToken) {
|
|
149
|
+
const minted = await mintAccessToken({ baseUrl: origin, cookieHeader, csrfToken });
|
|
150
|
+
if (minted.ok)
|
|
151
|
+
return build(minted.accessToken);
|
|
152
|
+
if (minted.reason === "transport") {
|
|
153
|
+
throw new LoginError("network", `Brightspace could not issue an API token right now (${minted.detail ?? "network"}).`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
// Fallback: the token Brightspace's own web app caches for itself.
|
|
157
|
+
const cached = await page.evaluate(() => {
|
|
158
|
+
try {
|
|
159
|
+
const tokens = JSON.parse(localStorage.getItem("D2L.Fetch.Tokens") ?? "null");
|
|
160
|
+
return tokens?.["*:*:*"]?.access_token ?? null;
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
}).catch(() => null);
|
|
166
|
+
if (cached)
|
|
167
|
+
return build(cached);
|
|
168
|
+
throw new LoginError("failed", "Signed in, but Brightspace did not provide an API token.");
|
|
169
|
+
}
|
|
170
|
+
async function readXsrfToken(page) {
|
|
171
|
+
for (let attempt = 0; attempt < 10; attempt += 1) {
|
|
172
|
+
const token = await page.evaluate(() => {
|
|
173
|
+
try {
|
|
174
|
+
const xsrf = window
|
|
175
|
+
.D2L?.LP?.Web?.Authentication?.Xsrf;
|
|
176
|
+
const value = xsrf?.GetXsrfToken?.call(xsrf);
|
|
177
|
+
if (value)
|
|
178
|
+
return value;
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
// Not ready yet.
|
|
182
|
+
}
|
|
183
|
+
return document.querySelector('meta[name="d2l-xsrf-token"]')?.getAttribute("content") ?? null;
|
|
184
|
+
}).catch(() => null);
|
|
185
|
+
if (token)
|
|
186
|
+
return token;
|
|
187
|
+
await sleep(1000);
|
|
188
|
+
}
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
async function saveCookies(context) {
|
|
192
|
+
try {
|
|
193
|
+
const cookies = await context.cookies();
|
|
194
|
+
const file = savedCookiesPath();
|
|
195
|
+
await fs.promises.mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
196
|
+
await writeFileAtomic(file, JSON.stringify(cookies), { mode: 0o600 });
|
|
197
|
+
}
|
|
198
|
+
catch (error) {
|
|
199
|
+
log("DEBUG", "Could not save sign-in cookies", error);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
async function restoreCookies(context) {
|
|
203
|
+
let cookies;
|
|
204
|
+
try {
|
|
205
|
+
cookies = JSON.parse(await fs.promises.readFile(savedCookiesPath(), "utf8"));
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const now = Date.now() / 1000;
|
|
211
|
+
const live = cookies.filter((c) => c.expires === -1 || c.expires > now);
|
|
212
|
+
// One malformed cookie must not block the rest.
|
|
213
|
+
for (const cookie of live) {
|
|
214
|
+
await context.addCookies([cookie]).catch(() => { });
|
|
215
|
+
}
|
|
216
|
+
log("DEBUG", `Restored ${live.length} sign-in cookies`);
|
|
217
|
+
}
|
|
218
|
+
function safeUrl(value) {
|
|
219
|
+
try {
|
|
220
|
+
return value ? new URL(value) : null;
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
227
|
+
//# sourceMappingURL=login.js.map
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { writeFileAtomic } from "../utils/atomic-write.js";
|
|
4
|
+
import { sessionPath } from "../utils/paths.js";
|
|
5
|
+
import { log } from "../utils/logger.js";
|
|
6
|
+
export class SessionStore {
|
|
7
|
+
file;
|
|
8
|
+
constructor(file = sessionPath()) {
|
|
9
|
+
this.file = file;
|
|
10
|
+
}
|
|
11
|
+
async load() {
|
|
12
|
+
try {
|
|
13
|
+
const data = JSON.parse(await fs.promises.readFile(this.file, "utf8"));
|
|
14
|
+
return typeof data?.accessToken === "string" ? data : null;
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
if (error?.code !== "ENOENT")
|
|
18
|
+
log("WARN", "Ignoring unreadable session file", error);
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
async save(token) {
|
|
23
|
+
await fs.promises.mkdir(path.dirname(this.file), { recursive: true, mode: 0o700 });
|
|
24
|
+
await writeFileAtomic(this.file, JSON.stringify(token), { mode: 0o600 });
|
|
25
|
+
}
|
|
26
|
+
async clear() {
|
|
27
|
+
await fs.promises.rm(this.file, { force: true });
|
|
28
|
+
}
|
|
29
|
+
async saveIfCurrent(token, expected) {
|
|
30
|
+
if (!same(await this.load(), expected))
|
|
31
|
+
return false;
|
|
32
|
+
await this.save(token);
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
async clearIfCurrent(expected) {
|
|
36
|
+
if (!same(await this.load(), expected))
|
|
37
|
+
return false;
|
|
38
|
+
await this.clear();
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function same(a, b) {
|
|
43
|
+
return !!a && a.accessToken === b.accessToken && a.capturedAt === b.capturedAt &&
|
|
44
|
+
a.cookieHeader === b.cookieHeader && a.csrfToken === b.csrfToken && a.tenantOrigin === b.tenantOrigin;
|
|
45
|
+
}
|
|
46
|
+
//# sourceMappingURL=session-store.js.map
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { SessionStore } from "./session-store.js";
|
|
2
|
+
import { mintAccessToken } from "./token-mint.js";
|
|
3
|
+
import { log } from "../utils/logger.js";
|
|
4
|
+
import { TokenRefreshError } from "../api/errors.js";
|
|
5
|
+
const REFRESH_BUFFER_MS = 5 * 60 * 1000; // 5 minutes
|
|
6
|
+
const DEFAULT_TOKEN_TTL_SECONDS = 3600;
|
|
7
|
+
export class TokenManager {
|
|
8
|
+
cachedToken = null;
|
|
9
|
+
sessionStore;
|
|
10
|
+
baseUrl;
|
|
11
|
+
tokenTtl;
|
|
12
|
+
mint;
|
|
13
|
+
mintInFlight = null;
|
|
14
|
+
rejectedAccessToken = null;
|
|
15
|
+
constructor(options = {}) {
|
|
16
|
+
this.sessionStore = options.sessionStore ?? new SessionStore(options.sessionFile);
|
|
17
|
+
this.baseUrl = options.baseUrl ? new URL(options.baseUrl).origin : undefined;
|
|
18
|
+
this.tokenTtl = options.tokenTtl ?? DEFAULT_TOKEN_TTL_SECONDS;
|
|
19
|
+
this.mint = options.mint ?? mintAccessToken;
|
|
20
|
+
}
|
|
21
|
+
async getToken(rejectedAccessToken) {
|
|
22
|
+
if (rejectedAccessToken)
|
|
23
|
+
this.rejectedAccessToken = rejectedAccessToken;
|
|
24
|
+
// Check memory cache first
|
|
25
|
+
if (this.cachedToken && this.isUsable(this.cachedToken)) {
|
|
26
|
+
log("DEBUG", "Returning cached token");
|
|
27
|
+
return this.cachedToken;
|
|
28
|
+
}
|
|
29
|
+
// Try loading from disk
|
|
30
|
+
const storedToken = await this.sessionStore.load();
|
|
31
|
+
if (storedToken && this.isUsable(storedToken)) {
|
|
32
|
+
log("DEBUG", "Loaded valid token from session store");
|
|
33
|
+
this.cachedToken = storedToken;
|
|
34
|
+
return storedToken;
|
|
35
|
+
}
|
|
36
|
+
const mintable = this.pickMintable(storedToken);
|
|
37
|
+
if (mintable) {
|
|
38
|
+
const minted = await this.mintFromSession(mintable);
|
|
39
|
+
if (minted)
|
|
40
|
+
return minted;
|
|
41
|
+
}
|
|
42
|
+
log("DEBUG", "No valid token available");
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
pickMintable(candidate) {
|
|
46
|
+
return this.baseUrl && candidate && this.matchesTenant(candidate) && candidate.cookieHeader && candidate.csrfToken ? candidate : null;
|
|
47
|
+
}
|
|
48
|
+
async mintFromSession(stale) {
|
|
49
|
+
if (this.mintInFlight) {
|
|
50
|
+
log("DEBUG", "Joining the in-flight token mint");
|
|
51
|
+
return this.mintInFlight;
|
|
52
|
+
}
|
|
53
|
+
const inFlight = this.runMint(stale).finally(() => {
|
|
54
|
+
this.mintInFlight = null;
|
|
55
|
+
});
|
|
56
|
+
this.mintInFlight = inFlight;
|
|
57
|
+
return inFlight;
|
|
58
|
+
}
|
|
59
|
+
async runMint(stale) {
|
|
60
|
+
log("DEBUG", "Trying to mint an access token from the session cookie");
|
|
61
|
+
let result;
|
|
62
|
+
try {
|
|
63
|
+
result = await this.mint({
|
|
64
|
+
baseUrl: this.baseUrl,
|
|
65
|
+
cookieHeader: stale.cookieHeader,
|
|
66
|
+
csrfToken: stale.csrfToken,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
throw new TokenRefreshError("token service request failed", error instanceof Error ? error : undefined);
|
|
71
|
+
}
|
|
72
|
+
// The auth CLI or another MCP process can finish a login while HTTP minting
|
|
73
|
+
// is in flight. Its newer session wins over either result of this request.
|
|
74
|
+
const current = await this.sessionStore.load();
|
|
75
|
+
if (current && !this.sameToken(current, stale)) {
|
|
76
|
+
this.cachedToken = current;
|
|
77
|
+
if (this.isUsable(current))
|
|
78
|
+
return current;
|
|
79
|
+
throw new TokenRefreshError("saved authentication changed during refresh");
|
|
80
|
+
}
|
|
81
|
+
if (result.ok) {
|
|
82
|
+
const now = Date.now();
|
|
83
|
+
const token = {
|
|
84
|
+
accessToken: result.accessToken,
|
|
85
|
+
tenantOrigin: this.baseUrl,
|
|
86
|
+
capturedAt: now,
|
|
87
|
+
expiresAt: now + this.tokenTtl * 1000,
|
|
88
|
+
source: "browser",
|
|
89
|
+
cookieHeader: stale.cookieHeader,
|
|
90
|
+
csrfToken: stale.csrfToken,
|
|
91
|
+
};
|
|
92
|
+
if (!await this.sessionStore.saveIfCurrent(token, stale))
|
|
93
|
+
return this.afterConcurrentChange();
|
|
94
|
+
this.cachedToken = token;
|
|
95
|
+
this.rejectedAccessToken = null;
|
|
96
|
+
log("INFO", "Minted a fresh access token from the session cookie");
|
|
97
|
+
return token;
|
|
98
|
+
}
|
|
99
|
+
if (result.reason === "sessionExpired") {
|
|
100
|
+
log("INFO", "The session cookie has expired, a browser login is needed");
|
|
101
|
+
if (!await this.sessionStore.clearIfCurrent(stale))
|
|
102
|
+
return this.afterConcurrentChange();
|
|
103
|
+
this.cachedToken = null;
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
throw new TokenRefreshError(result.detail ?? "unexpected token service response");
|
|
107
|
+
}
|
|
108
|
+
async setToken(token) {
|
|
109
|
+
await this.sessionStore.save(token);
|
|
110
|
+
this.cachedToken = token;
|
|
111
|
+
this.rejectedAccessToken = null;
|
|
112
|
+
log("DEBUG", "Token cached and persisted");
|
|
113
|
+
}
|
|
114
|
+
async clearToken() {
|
|
115
|
+
this.cachedToken = null;
|
|
116
|
+
await this.sessionStore.clear();
|
|
117
|
+
log("DEBUG", "Token cleared from memory and disk");
|
|
118
|
+
}
|
|
119
|
+
isUsable(token) {
|
|
120
|
+
return this.matchesTenant(token) && token.accessToken !== this.rejectedAccessToken && this.isValid(token);
|
|
121
|
+
}
|
|
122
|
+
matchesTenant(token) {
|
|
123
|
+
return this.baseUrl === undefined || token.tenantOrigin === this.baseUrl;
|
|
124
|
+
}
|
|
125
|
+
sameToken(a, b) {
|
|
126
|
+
return a.accessToken === b.accessToken && a.capturedAt === b.capturedAt &&
|
|
127
|
+
a.cookieHeader === b.cookieHeader && a.csrfToken === b.csrfToken && a.tenantOrigin === b.tenantOrigin;
|
|
128
|
+
}
|
|
129
|
+
async afterConcurrentChange() {
|
|
130
|
+
this.cachedToken = await this.sessionStore.load();
|
|
131
|
+
if (this.cachedToken && this.isUsable(this.cachedToken))
|
|
132
|
+
return this.cachedToken;
|
|
133
|
+
throw new TokenRefreshError("saved authentication changed during refresh");
|
|
134
|
+
}
|
|
135
|
+
isValid(token) {
|
|
136
|
+
const now = Date.now();
|
|
137
|
+
const timeUntilExpiry = token.expiresAt - now;
|
|
138
|
+
// Token must expire more than REFRESH_BUFFER_MS in the future
|
|
139
|
+
const valid = timeUntilExpiry > REFRESH_BUFFER_MS;
|
|
140
|
+
if (!valid) {
|
|
141
|
+
log("DEBUG", `Token invalid: expires in ${Math.round(timeUntilExpiry / 1000)}s (buffer: ${REFRESH_BUFFER_MS / 1000}s)`);
|
|
142
|
+
}
|
|
143
|
+
return valid;
|
|
144
|
+
}
|
|
145
|
+
async needsRefresh() {
|
|
146
|
+
const token = await this.getToken();
|
|
147
|
+
return token === null;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
//# sourceMappingURL=token-manager.js.map
|