diff --git a/docs/CLI_REFERENCE.md b/docs/CLI_REFERENCE.md index e127f69..e72e443 100644 --- a/docs/CLI_REFERENCE.md +++ b/docs/CLI_REFERENCE.md @@ -86,7 +86,7 @@ uit grades 'https://courses.uit.edu.vn/course/view.php?id=19207' ### `uit login` -Sign in to UIT Moodle. **UIT SSO is the default**; `--legacy` restores the v1.0/v1.1 Student ID/password flow for the old Moodle portal. +Sign in to UIT Moodle. **UIT SSO is the default**; `--legacy` opens the selected legacy Moodle portal in bundled Chromium. ```bash # Recommended: Sign in via UIT SSO in browser (default) @@ -95,16 +95,16 @@ uit login # Explicit SSO: uit login --sso -# Legacy Moodle: prompts for Student ID and password, then stores the returned token: +# Undergraduate legacy Moodle: uit login --legacy -# Non-interactive legacy token setup: -uit login --legacy --username YOUR_STUDENT_ID --password YOUR_PASSWORD +# Graduate legacy Moodle: +uit login --legacy --graduate ``` -Prefer the interactive browser login (`uit login`) or `uit login --legacy` for normal use. Passwords passed as command-line arguments can be saved in shell history. The CLI does not save your password; it stores only the session/token. +Both flows open the official portal in bundled Chromium. Sign in there; UIT stores the Moodle session cookies and `sesskey`, never the password. Web-service-token authentication is not supported. -SSO and token sessions are saved to `~/.uit/sessions.json` (mode `0600` on Unix) and shared with UIT Studio. The user ID is discovered during login and stored with the session. Re-run `uit login` at any time to refresh or rotate it. +Sessions are saved to `~/.uit/sessions.json` (mode `0600` on Unix) and shared with UIT Studio. The user ID is discovered during login and stored with the session. Re-run the relevant `uit login` command to refresh it. --- @@ -372,7 +372,7 @@ uit grades 19207 ### `uit functions [keyword]` -List the 420+ Moodle web service functions available to your token. Grouped by module. +List the Moodle API functions exposed to your signed-in account, grouped by module. ```bash uit functions # list all diff --git a/packages/uit-runtime/package.json b/packages/uit-runtime/package.json index 25642fe..9cb22b4 100644 --- a/packages/uit-runtime/package.json +++ b/packages/uit-runtime/package.json @@ -17,7 +17,7 @@ "LICENSE" ], "scripts": { - "postinstall": "node dist/studio-sso.js --install-browser", + "postinstall": "node dist/moodle-browser-login.js --install-browser", "prepack": "npm --prefix ../.. run build && node ../../scripts/prepare-runtime-package.mjs" }, "dependencies": { diff --git a/scripts/check-studio-package.mjs b/scripts/check-studio-package.mjs index 35c58f1..ebe5dcc 100644 --- a/scripts/check-studio-package.mjs +++ b/scripts/check-studio-package.mjs @@ -62,7 +62,7 @@ for (const required of [ "dist/session-health.js", "dist/studio-core.js", "dist/studio-thread-store.js", - "dist/studio-sso.js", + "dist/moodle-browser-login.js", "dist/studio-web-server.js", "dist/studio-web-launcher.js", "dist/uit-tools.js", diff --git a/scripts/package-studio-standalone.mjs b/scripts/package-studio-standalone.mjs index 566bd4f..094a426 100644 --- a/scripts/package-studio-standalone.mjs +++ b/scripts/package-studio-standalone.mjs @@ -195,7 +195,7 @@ async function main() { const browserRoot = join(runtimeRoot, "browsers"); const browserEnvironment = { ...process.env, PLAYWRIGHT_BROWSERS_PATH: browserRoot, UIT_STUDIO_CHROMIUM_DIR: browserRoot }; delete browserEnvironment.PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD; - const installScript = `import { installBundledChromium } from ${JSON.stringify(pathToFileURL(join(runtimeRoot, "dist", "studio-sso.js")).href)}; installBundledChromium();`; + const installScript = `import { installBundledChromium } from ${JSON.stringify(pathToFileURL(join(runtimeRoot, "dist", "moodle-browser-login.js")).href)}; installBundledChromium();`; run(nodePath, ["--input-type=module", "-e", installScript], { cwd: appRoot, env: browserEnvironment }); const executablePath = chromiumManifest(runtimeRoot, platform, architecture); console.log(`Bundled Chromium: ${executablePath}`); diff --git a/scripts/prepare-runtime-package.mjs b/scripts/prepare-runtime-package.mjs index 61207a2..f4f1db5 100644 --- a/scripts/prepare-runtime-package.mjs +++ b/scripts/prepare-runtime-package.mjs @@ -24,7 +24,7 @@ const sharedModules = [ "studio-core", "notifications", "studio-thread-store", - "studio-sso", + "moodle-browser-login", "studio-web-server", "studio-web-launcher", "moodle-session-client", diff --git a/src/api.ts b/src/api.ts index f7f345e..7fdf359 100644 --- a/src/api.ts +++ b/src/api.ts @@ -4,7 +4,7 @@ import { createHash, randomUUID } from "node:crypto"; import { basename, dirname } from "node:path"; import { Readable, Transform } from "node:stream"; import { pipeline } from "node:stream/promises"; -import { get } from "./config.js"; +import { getActiveConfig } from "./config.js"; import { buildAjaxInfo, normalizeArgs, unwrapAjaxResponse } from "./ajax-helpers.js"; import type { ApiClient, MoodleRecord } from "./types.js"; @@ -16,6 +16,34 @@ declare module "./types.js" { export const MAX_PREVIEW_BYTES = 25 * 1024 * 1024; +/** Resolve Moodle paths without dropping an installation prefix such as /sdh. */ +export function resolveMoodleUrl(baseUrl: string, path: string): URL { + const base = new URL(baseUrl); + const prefix = base.pathname.replace(/\/+$/, ""); + const root = new URL(`${base.origin}${prefix}/`); + const target = new URL(path, root); + if (target.origin === base.origin && prefix && target.pathname !== prefix && !target.pathname.startsWith(`${prefix}/`)) { + target.pathname = `${prefix}${target.pathname.startsWith("/") ? "" : "/"}${target.pathname}`; + } + return target; +} + +function resolveMoodleRedirect(baseUrl: string, currentUrl: URL, location: string): URL { + const target = new URL(location, currentUrl); + const base = new URL(baseUrl); + const prefix = base.pathname.replace(/\/+$/, ""); + if (target.origin === base.origin && prefix && target.pathname !== prefix && !target.pathname.startsWith(`${prefix}/`)) { + target.pathname = `${prefix}${target.pathname.startsWith("/") ? "" : "/"}${target.pathname}`; + } + return target; +} + +function moodleBaseUrlFromPage(pageUrl: string): string { + const page = new URL(pageUrl); + const prefix = page.pathname.match(/^\/sdh(?:\/|$)/) ? "/sdh" : ""; + return page.origin + prefix; +} + function unavailableSessionMethod(error: unknown): boolean { const code = String((error as { errorcode?: string })?.errorcode || ""); if (code && !/^(?:moodle_exception|webservice_exception)$/i.test(code)) { @@ -104,7 +132,7 @@ function hasSubmissionFormError(html: string): boolean { function fileRecord(rawUrl: string, pageUrl: string): MoodleRecord | undefined { try { - const url = new URL(rawUrl, pageUrl); + const url = resolveMoodleUrl(moodleBaseUrlFromPage(pageUrl), rawUrl); if (url.origin !== new URL(pageUrl).origin || !/(?:token)?pluginfile\.php(?:\/|$)|\/mod_forum\/attachment(?:\/|$)/i.test(url.pathname)) return undefined; let filename = basename(url.pathname) || "resource"; try { filename = decodeURIComponent(filename); } catch { /* Preserve malformed Moodle filenames verbatim. */ } @@ -134,10 +162,10 @@ export function credentialFreeUrl(value: unknown): string | undefined { } /** Follow redirects manually so credentials never leave the authenticated origin. */ -export async function fetchCourseFile(baseUrl: string, fileUrl: string, headers: Record = {}, token?: string): Promise { +export async function fetchCourseFile(baseUrl: string, fileUrl: string, headers: Record = {}): Promise { const base = new URL(baseUrl); const installationPath = base.pathname.replace(/\/+$/, ""); - let url = new URL(fileUrl, `${baseUrl.replace(/\/+$/, "")}/`); + let url = resolveMoodleUrl(baseUrl, fileUrl); const signal = AbortSignal.timeout(120_000); for (let redirects = 0; redirects <= 5; redirects++) { if (url.origin !== base.origin || url.username || url.password) throw new Error("Refusing to send UIT credentials to another origin."); @@ -147,19 +175,15 @@ export async function fetchCourseFile(baseUrl: string, fileUrl: string, headers: url = new URL(clean); const path = url.pathname.slice(installationPath.length); const pluginfile = url.pathname.startsWith(`${installationPath}/`) && /^\/(?:webservice\/)?pluginfile\.php(?:\/|$)/.test(path); - if (token && !pluginfile) throw new Error("Unsupported token course file endpoint."); if (pluginfile) { - // https://moodledev.io/docs/4.5/apis/subsystems/external/files - // Mobile tokens use webservice/pluginfile; cookies use ordinary pluginfile. - url.pathname = `${installationPath}${path.replace(/^\/(?:webservice\/)?pluginfile\.php/, token ? "/webservice/pluginfile.php" : "/pluginfile.php")}`; + url.pathname = `${installationPath}${path.replace(/^\/(?:webservice\/)?pluginfile\.php/, "/pluginfile.php")}`; } - if (token) url.searchParams.set("token", token); const response = await fetch(url, { headers, redirect: "manual", signal }); if ([301, 302, 303, 307, 308].includes(response.status)) { await response.body?.cancel(); const location = response.headers.get("location"); if (!location) throw new Error("Course file redirect has no destination."); - url = new URL(location, url); + url = resolveMoodleRedirect(baseUrl, url, location); continue; } if (!response.ok) { @@ -250,68 +274,6 @@ export async function writeCourseFile(response: Response, destPath: string, opti } } -function appendParams(url: URL, params: Record): void { - for (const [key, value] of Object.entries(params)) { - if (value === undefined || value === null) continue; - url.searchParams.set(key, String(value)); - } -} - -export function createTokenApiClient(baseUrl: string, token: string): ApiClient { - const normalizedBaseUrl = baseUrl.replace(/\/+$/, ""); - const callWithToken = async (name: string, params: Record = {}): Promise => { - const url = new URL(`${normalizedBaseUrl}/webservice/rest/server.php`); - appendParams(url, { - ...params, - wstoken: token, - wsfunction: name, - moodlewsrestformat: "json" - }); - - const response = await fetch(url, { signal: AbortSignal.timeout(30_000) }); - if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`); - const data = await response.json(); - if (data && typeof data === "object" && "exception" in data) { - const error = new Error(data.message || data.error || JSON.stringify(data)) as Error & { - errorcode?: string; - moodleException?: string; - }; - if (typeof data.errorcode === "string") error.errorcode = data.errorcode; - if (typeof data.exception === "string") error.moodleException = data.exception; - throw error; - } - return data as T; - }; - - const uploadWithToken = async (filepath: string): Promise => { - const form = new FormData(); - form.append("token", token); - form.append("filearea", "draft"); - form.append("itemid", "0"); - form.append("file", await openAsBlob(filepath), basename(filepath)); - - const response = await fetch(`${normalizedBaseUrl}/webservice/upload.php`, { - method: "POST", - body: form, - signal: AbortSignal.timeout(120_000) - }); - if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`); - const data = await response.json(); - if (Array.isArray(data) && data.length > 0) return data[0]; - if (data && typeof data === "object" && "error" in data) throw new Error(String(data.error)); - return data as MoodleRecord; - }; - - const downloadWithToken = async (fileUrl: string, destPath: string, options?: { atomic?: boolean }): Promise<{ sha256: string }> => { - return writeCourseFile(await fetchCourseFile(normalizedBaseUrl, fileUrl, {}, token), destPath, options); - }; - - return { - call: callWithToken, uploadFile: uploadWithToken, downloadFile: downloadWithToken, - readFile: async (fileUrl) => readCourseFile(await fetchCourseFile(normalizedBaseUrl, fileUrl, {}, token)) - }; -} - export async function call(name: string, params: Record = {}): Promise { return defaultApiClient.call(name, params); } @@ -349,7 +311,7 @@ export class NodeSessionApiClient implements ApiClient { } private async fetchHtmlPage(path: string): Promise<{ html: string; url: string }> { - const url = new URL(path, `${this.baseUrl}/`); + const url = resolveMoodleUrl(this.baseUrl, path); if (url.origin !== new URL(this.baseUrl).origin) throw new Error("Course page belongs to another origin."); const res = await fetch(url, { headers: { Cookie: this.cookieHeader }, @@ -407,7 +369,7 @@ export class NodeSessionApiClient implements ApiClient { course: courseId, name: name || "Activity", modname: modnameMatch?.[1] || (/\/mod\/([^/]+)\//i.exec(urlMatch?.[1] || "")?.[1]) || "resource", - url: urlMatch ? new URL(urlMatch[1].replace(/&/gi, "&"), pageUrl).toString() : "", + url: urlMatch ? resolveMoodleUrl(this.baseUrl, urlMatch[1].replace(/&/gi, "&")).toString() : "", description: htmlVisibleText(/class=["'][^"']*\b(?:contentwithoutlink|activity-description)\b[^"']*["'][^>]*>([\s\S]*?)<\/(?:div|section)>/i.exec(block)?.[1]), contents }); @@ -418,7 +380,7 @@ export class NodeSessionApiClient implements ApiClient { await Promise.all(modules.slice(start, start + 4).map(async (module) => { if (!["resource", "folder", "url"].includes(String(module.modname))) return; try { - const activityUrl = new URL(String(module.url || `/mod/${module.modname}/view.php?id=${module.id}`), pageUrl); + const activityUrl = resolveMoodleUrl(this.baseUrl, String(module.url || `/mod/${module.modname}/view.php?id=${module.id}`)); if (activityUrl.origin !== new URL(this.baseUrl).origin || !activityUrl.pathname.includes(`/mod/${module.modname}/`)) { throw new Error("Moodle returned an invalid activity URL."); } @@ -449,7 +411,7 @@ export class NodeSessionApiClient implements ApiClient { const modules = sections.flatMap((section) => section.modules || []).filter((module) => module.modname === "assign"); const assignments = await Promise.all(modules.map(async (module) => { try { - const activityUrl = new URL(String(module.url || `/mod/assign/view.php?id=${module.id}`), this.baseUrl); + const activityUrl = resolveMoodleUrl(this.baseUrl, String(module.url || `/mod/assign/view.php?id=${module.id}`)); if (activityUrl.origin !== new URL(this.baseUrl).origin || !activityUrl.pathname.includes("/mod/assign/")) { throw new Error("Moodle returned an invalid assignment URL."); } @@ -492,7 +454,7 @@ export class NodeSessionApiClient implements ApiClient { } private async fetchForumActivityHtml(module: MoodleRecord): Promise { - const activityUrl = new URL(String(module.url || ("/mod/forum/view.php?id=" + module.id)), this.baseUrl); + const activityUrl = resolveMoodleUrl(this.baseUrl, String(module.url || ("/mod/forum/view.php?id=" + module.id))); if (activityUrl.origin !== new URL(this.baseUrl).origin || !activityUrl.pathname.includes("/mod/forum/")) { throw new Error("Moodle returned an invalid forum URL."); } @@ -555,7 +517,7 @@ export class NodeSessionApiClient implements ApiClient { const path = byModule ? "/mod/forum/view.php?id=" + cmid + "&forceview=1&p=" + page + "&s=" + perpage : "/mod/forum/view.php?f=" + forumId + "&p=" + page + "&s=" + perpage; - const { html, url: pageUrl } = await this.fetchHtmlPage(path); + const { html } = await this.fetchHtmlPage(path); if (!/(?:discussion-list|forumheaderlist|forumnodiscuss|forumpost)/i.test(html)) { throw new Error("Unable to read forum discussions."); } @@ -581,7 +543,7 @@ export class NodeSessionApiClient implements ApiClient { let discussionUrl: URL | undefined; if (linkMatch) { try { - const candidate = new URL(linkMatch[2].replace(/&/gi, "&"), pageUrl); + const candidate = resolveMoodleUrl(this.baseUrl, linkMatch[2].replace(/&/gi, "&")); if (candidate.origin === new URL(this.baseUrl).origin && candidate.pathname.includes("/mod/forum/")) discussionUrl = candidate; } catch { /* Ignore malformed discussion links. */ } } @@ -598,7 +560,7 @@ export class NodeSessionApiClient implements ApiClient { const repliesMatch = /<([a-z0-9]+)\b[^>]*class=["'][^"']*\breplies\b[^"']*["'][^>]*>([\s\S]*?)<\/\1>/i.exec(row) || /]*class=["'][^"']*\btext-center\b[^"']*["'][^>]*>([\s\S]*?)<\/td>/i.exec(row); const count = Number(htmlText(repliesMatch?.[2] || repliesMatch?.[1])); - const cleanUrl = credentialFreeUrl(discussionUrl?.toString() || new URL("/mod/forum/discuss.php?d=" + discussion, pageUrl).toString()); + const cleanUrl = credentialFreeUrl(discussionUrl?.toString() || resolveMoodleUrl(this.baseUrl, "/mod/forum/discuss.php?d=" + discussion).toString()); discussions.push({ discussion, name: htmlText(title || (linkMatch ? linkMatch[3] : "")), @@ -699,7 +661,7 @@ export class NodeSessionApiClient implements ApiClient { }; if (avatar) { try { - const avatarUrl = credentialFreeUrl(new URL(avatar.replace(/&/gi, "&"), pageUrl).toString()); + const avatarUrl = credentialFreeUrl(resolveMoodleUrl(this.baseUrl, avatar.replace(/&/gi, "&")).toString()); if (avatarUrl) user.profileimageurl = avatarUrl; } catch { /* Ignore malformed avatar URLs. */ } } @@ -773,7 +735,7 @@ export class NodeSessionApiClient implements ApiClient { } if (module.modname === "forum") { - const activityUrl = new URL(String(module.url || `/mod/forum/view.php?id=${cmid}`), this.baseUrl); + const activityUrl = resolveMoodleUrl(this.baseUrl, String(module.url || `/mod/forum/view.php?id=${cmid}`)); if (activityUrl.origin !== new URL(this.baseUrl).origin || !activityUrl.pathname.includes("/mod/forum/")) { throw new Error("Moodle returned an invalid forum URL."); } @@ -969,7 +931,7 @@ export class NodeSessionApiClient implements ApiClient { } private async postHtmlForm(path: string, body: URLSearchParams): Promise<{ html: string; url: string }> { - const url = new URL(path, `${this.baseUrl}/`); + const url = resolveMoodleUrl(this.baseUrl, path); if (url.origin !== new URL(this.baseUrl).origin) throw new Error("Course page belongs to another origin."); const res = await fetch(url, { method: "POST", @@ -983,10 +945,10 @@ export class NodeSessionApiClient implements ApiClient { }); if ([301, 302, 303, 307, 308].includes(res.status)) { const location = res.headers.get("location") || ""; - if (/\/login(?:\/|$)/i.test(new URL(location, url).pathname)) { + const destination = resolveMoodleRedirect(this.baseUrl, url, location); + if (/\/login(?:\/|$)/i.test(destination.pathname)) { throw new Error("UIT session expired. Please sign in again."); } - const destination = new URL(location, url); if (destination.origin !== url.origin) throw new Error("Moodle form redirected to another origin."); return { html: "", url: destination.toString() }; } @@ -1021,10 +983,11 @@ export class NodeSessionApiClient implements ApiClient { } const response = await this.postHtmlForm(`/mod/assign/view.php?id=${cmid}`, body); - const responseUrl = new URL(response.url, `${this.baseUrl}/`); + const responseUrl = resolveMoodleUrl(this.baseUrl, response.url); + const installationPath = new URL(this.baseUrl).pathname.replace(/\/+$/, ""); if ( responseUrl.origin !== new URL(this.baseUrl).origin || - responseUrl.pathname !== "/mod/assign/view.php" || + responseUrl.pathname !== `${installationPath}/mod/assign/view.php` || responseUrl.searchParams.get("id") !== String(cmid) ) { throw new Error("Moodle returned an unexpected submission response."); @@ -1138,15 +1101,9 @@ export function createSessionApiClient( } export function getActiveApiClient(): ApiClient { - const authType = get("authType"); - if (authType === "sso") { - const sesskey = get("sesskey"); - const cookies = get("cookies"); - if (sesskey && cookies) { - return createSessionApiClient(get("baseUrl"), sesskey, cookies); - } - } - return createTokenApiClient(get("baseUrl"), get("token")); + const config = getActiveConfig(); + if (!config.sesskey || !config.cookies?.length) throw new Error("The active UIT browser session is incomplete. Sign in again."); + return createSessionApiClient(config.baseUrl, config.sesskey, config.cookies); } export const defaultApiClient: ApiClient = { diff --git a/src/cli.ts b/src/cli.ts index d8529c0..2136428 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -14,7 +14,6 @@ import { cmdEvents, cmdFunctions, cmdGrades, - cmdInit, cmdOpen, cmdRaw, cmdReply, @@ -25,17 +24,19 @@ import { createContext } from "./commands.js"; import { runMcpServer, installMcpServer } from "./mcp-server.js"; -import { cmdLoginSso, type SsoLoginLauncher } from "./sso-login.js"; +import { cmdLoginLegacy, cmdLoginSso, type LegacyLoginLauncher, type SsoLoginLauncher } from "./sso-login.js"; import { VERSION } from "./version.js"; import { registerNotificationCommands } from "./notification-commands.js"; const CURRENT_SITE_BASE_URL = "https://courses.uit.edu.vn"; const LEGACY_SITE_BASE_URL = "https://coursesold.uit.edu.vn"; +const GRADUATE_SITE_BASE_URL = `${LEGACY_SITE_BASE_URL}/sdh`; export const WORKFLOW = ` workflow: uit login -> sign in via UIT SSO (default) - uit login --legacy -> sign in to legacy Moodle with Student ID/password + uit login --legacy -> sign in to undergraduate legacy Moodle in Chromium + uit login --legacy --graduate -> sign in to graduate legacy Moodle in Chromium uit courses --current -> get course IDs uit contents -> browse modules (shows module IDs) uit view -> inspect any module (accepts module_id or assign_id) @@ -53,7 +54,7 @@ export const WORKFLOW = ` uit view-discussion -> read forum thread (shows post IDs) uit reply -> reply to a forum post uit open -> open in browser (module, course, or URL) - uit functions [keyword] -> discover 420+ raw API functions + uit functions [keyword] -> discover available Moodle API functions uit raw key=value -> call any Moodle API function ID chain: courses -> course_id -> contents / download / announcements / deadlines / grades @@ -122,7 +123,7 @@ function printLanding(): void { export function createProgram( api: ApiClient = defaultApiClient, - options: { openBrowser?: boolean; ssoLauncher?: SsoLoginLauncher } = {} + options: { openBrowser?: boolean; ssoLauncher?: SsoLoginLauncher; legacyLauncher?: LegacyLoginLauncher } = {} ): Command { const ctx = createContext(api); const program = new Command(); @@ -142,23 +143,13 @@ export function createProgram( .command("login") .description("Sign in via UIT SSO (default) or legacy Moodle") .option("--sso", "Sign in via UIT SSO in browser window") - .option("--legacy", "Sign in to legacy Moodle with Student ID/password") - .option("-u, --username ", "Student ID for legacy token setup") - .option("-p, --password ", "Password for non-interactive legacy token setup") + .option("--legacy", "Sign in to legacy Moodle in a browser window") + .option("--graduate", "Use the graduate legacy portal (/sdh); requires --legacy") .action(async (opts) => { - const hasLegacyCredentials = Boolean(opts.username || opts.password); - if (opts.legacy && opts.sso) { - throw new CliError("Choose one login method: --sso or --legacy."); - } - if (opts.sso && hasLegacyCredentials) { - throw new CliError("--sso cannot be combined with --username or --password."); - } - if (opts.legacy || hasLegacyCredentials) { - await cmdInit({ - url: LEGACY_SITE_BASE_URL, - username: opts.username, - password: opts.password - }); + if (opts.legacy && opts.sso) throw new CliError("Choose one login method: --sso or --legacy."); + if (opts.graduate && !opts.legacy) throw new CliError("--graduate requires --legacy."); + if (opts.legacy) { + await cmdLoginLegacy({ url: opts.graduate ? GRADUATE_SITE_BASE_URL : LEGACY_SITE_BASE_URL }, options.legacyLauncher); return; } await cmdLoginSso({ url: CURRENT_SITE_BASE_URL }, options.ssoLauncher); @@ -265,7 +256,7 @@ export function createProgram( program .command("functions") - .description("List/search available Moodle API functions (420+)") + .description("List/search Moodle API functions exposed to your account") .argument("[query]", "Filter by keyword, e.g. 'assign', 'quiz', 'forum'", "") .action((query) => cmdFunctions({ query }, ctx)); diff --git a/src/commands.ts b/src/commands.ts index 15dcef9..640e5da 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -2,10 +2,8 @@ import { existsSync } from "node:fs"; import { basename, join, resolve, sep } from "node:path"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; -import { createInterface } from "node:readline/promises"; -import { Writable } from "node:stream"; import { defaultApiClient } from "./api.js"; -import { get, save } from "./config.js"; +import { get } from "./config.js"; import { listH5pActivities, readH5pActivity } from "./h5p.js"; import type { ApiClient, MoodleRecord } from "./types.js"; import { extractH5pPackage } from "./unzip.js"; @@ -56,90 +54,6 @@ function formatSize(size: number): string { return size < 1_048_576 ? `${(size / 1024).toFixed(0)}KB` : `${(size / 1_048_576).toFixed(1)}MB`; } -async function initSiteInfo(token: string, baseUrl: string): Promise { - const url = new URL(`${baseUrl}/webservice/rest/server.php`); - url.searchParams.set("wstoken", token); - url.searchParams.set("wsfunction", "core_webservice_get_site_info"); - url.searchParams.set("moodlewsrestformat", "json"); - const response = await fetch(url, { signal: AbortSignal.timeout(15_000) }); - if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`); - return response.json() as Promise; -} - -export async function requestMobileToken(baseUrl: string, username: string, password: string): Promise { - const response = await fetch(`${baseUrl}/login/token.php`, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - username, - password, - service: "moodle_mobile_app" - }), - signal: AbortSignal.timeout(15_000) - }); - if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`); - const data = (await response.json()) as MoodleRecord; - if (data.error) die(String(data.error)); - if (!data.token) die("Moodle did not return a token", "Check your username/password and whether Moodle Mobile services are enabled."); - return String(data.token); -} - -async function promptText(label: string): Promise { - if (!process.stdin.isTTY) die("Cannot prompt for credentials without an interactive terminal."); - const rl = createInterface({ input: process.stdin, output: process.stderr }); - try { - return (await rl.question(label)).trim(); - } finally { - rl.close(); - } -} - -async function promptPassword(label: string): Promise { - if (!process.stdin.isTTY) die("Cannot prompt for credentials without an interactive terminal."); - process.stderr.write(label); - const mutedOutput = new Writable({ - write(_chunk, _encoding, callback) { - callback(); - } - }) as Writable & { isTTY?: boolean; columns?: number }; - mutedOutput.isTTY = true; - mutedOutput.columns = process.stderr.columns || 80; - - const rl = createInterface({ - input: process.stdin, - output: mutedOutput, - terminal: true - }); - try { - return await rl.question(""); - } finally { - rl.close(); - process.stderr.write("\n"); - } -} - -async function resolveInitToken(args: { token?: string; username?: string; password?: string }, baseUrl: string): Promise { - if (args.token) return args.token; - - const username = args.username || (await promptText("Student ID: ")); - const password = args.password || (await promptPassword("Password: ")); - if (!username) die("Student ID is required."); - if (!password) die("Password is required."); - - loading("Requesting Moodle token..."); - return requestMobileToken(baseUrl, username, password); -} - -export async function cmdInit(args: { token?: string; url: string; username?: string; password?: string }): Promise { - const baseUrl = args.url.replace(/\/+$/, ""); - const token = await resolveInitToken(args, baseUrl); - const data = await initSiteInfo(token, baseUrl); - if (data.exception) die(data.message || "invalid token"); - const userId = data.userid; - save(token, userId, baseUrl); - out({ status: "ok", user: data.fullname, user_id: userId, site: data.sitename }); -} - export async function cmdCourses(args: { current?: boolean }, ctx = createContext()): Promise { loading("Loading courses..."); let courses = await ctx.api.call("core_enrol_get_users_courses", { userid: get("userId") }); @@ -775,9 +689,7 @@ export async function cmdDownload( if (isH5p && args.extract) extractPackage(record, dest); results.push(record); } catch (error) { - let message = error instanceof Error ? error.message : String(error); - const token = get("token"); - if (token && message.includes(token)) message = message.replaceAll(token, "***"); + const message = error instanceof Error ? error.message : String(error); results.push({ file: file.filename, status: "error", error: message }); if (!isJsonMode()) console.log(` FAIL: ${message}`); } diff --git a/src/config.ts b/src/config.ts index 77d502a..3fd51c1 100644 --- a/src/config.ts +++ b/src/config.ts @@ -4,7 +4,7 @@ import { join } from "node:path"; import { homedir } from "node:os"; import { CliError } from "./output.js"; -export interface SsoCookie { +export interface MoodleSessionCookie { name: string; value: string; domain?: string; @@ -13,33 +13,34 @@ export interface SsoCookie { httpOnly?: boolean; } -export interface SsoSessionData { +export interface MoodleBrowserSessionData { baseUrl: string; userId: number; sesskey: string; - cookies: SsoCookie[]; + cookies: MoodleSessionCookie[]; savedAt?: number; } -export interface LegacySessionData { - baseUrl: string; - userId: number; - token: string; +export interface LegacyBrowserSessionData extends MoodleBrowserSessionData { + authType: "session"; } +export type LegacySessionData = LegacyBrowserSessionData; + +export type SessionAuthType = "sso" | "session"; + export interface SessionsData { - sso?: SsoSessionData | null; + sso?: MoodleBrowserSessionData | null; legacy?: LegacySessionData[] | null; - active?: { authType: "token" | "sso"; baseUrl: string } | null; + active?: { authType: SessionAuthType; baseUrl: string } | null; } export interface Config { - authType: "token" | "sso"; + authType: SessionAuthType; baseUrl: string; userId: number | null; - token?: string; sesskey?: string; - cookies?: SsoCookie[]; + cookies?: MoodleSessionCookie[]; } export const getSessionsFilePath = (): string => join(homedir(), ".uit", "sessions.json"); @@ -83,42 +84,61 @@ export function writeSessionsFile(data: SessionsData): void { function load(): Config { if (cfg) return cfg; - // 1. Environment variable override (e.g. CI/CD or scripts) + // Web-service-token authentication is no longer supported. Fail explicitly + // instead of silently selecting another saved session when stale env vars remain. if (process.env.UIT_TOKEN) { - const baseUrl = (process.env.UIT_BASE_URL || "https://courses.uit.edu.vn").replace(/\/+$/, ""); - const userId = process.env.UIT_USER_ID ? Number.parseInt(process.env.UIT_USER_ID, 10) : null; - cfg = { - authType: "token", - token: process.env.UIT_TOKEN, - baseUrl, - userId: Number.isFinite(userId) ? userId : null - }; - return cfg; + throw new CliError("UIT_TOKEN authentication is no longer supported. Run uit login or uit login --legacy to sign in in a browser."); } - // 2. Read ~/.uit/sessions.json + // Read the shared browser-session store. const sessions = readSessionsFile(); - // 2a. Honor the last explicit CLI login when both session types exist. - if (sessions.active?.authType === "token") { - const record = (sessions.legacy || []).find((item) => item.baseUrl === sessions.active?.baseUrl); - if (record?.token) { - cfg = { authType: "token", token: record.token, baseUrl: record.baseUrl, userId: Number(record.userId) }; - return cfg; + // An explicit selection is authoritative. Never fall through to another + // account when the selected session is missing or uses a retired auth mode. + if (sessions.active) { + const activeBaseUrl = sessions.active.baseUrl?.replace(/\/+$/, ""); + if (sessions.active.authType === "session") { + const record = (sessions.legacy || []).find((item) => item.baseUrl?.replace(/\/+$/, "") === activeBaseUrl); + if (record && record.authType === "session" && record.sesskey && Array.isArray(record.cookies) && record.cookies.length) { + cfg = { + authType: "session", + baseUrl: record.baseUrl.replace(/\/+$/, ""), + userId: Number(record.userId), + sesskey: record.sesskey, + cookies: record.cookies + }; + return cfg; + } + throw new CliError("The selected UIT session is no longer saved. Sign in again."); } + if (sessions.active.authType === "sso") { + const record = sessions.sso; + if (record && record.baseUrl?.replace(/\/+$/, "") === activeBaseUrl && record.sesskey && Array.isArray(record.cookies) && record.cookies.length) { + cfg = { + authType: "sso", + baseUrl: record.baseUrl.replace(/\/+$/, ""), + userId: Number(record.userId), + sesskey: record.sesskey, + cookies: record.cookies + }; + return cfg; + } + throw new CliError("The selected UIT session is no longer saved. Sign in again."); + } + throw new CliError("The saved active UIT session uses an unsupported authentication method. Sign in again."); } - // 2b. Check SSO session + // With no explicit selection, use a saved SSO session before legacy sessions. if ( sessions.sso && sessions.sso.baseUrl && sessions.sso.sesskey && sessions.sso.userId && - Array.isArray(sessions.sso.cookies) + Array.isArray(sessions.sso.cookies) && + sessions.sso.cookies.length ) { cfg = { authType: "sso", - token: "", baseUrl: sessions.sso.baseUrl.replace(/\/+$/, ""), userId: Number(sessions.sso.userId), sesskey: sessions.sso.sesskey, @@ -127,20 +147,17 @@ function load(): Config { return cfg; } - // 2c. Check Legacy token session - if (sessions.legacy && sessions.legacy.length > 0) { - const record = sessions.legacy[0]; - if (record && record.token) { - const baseUrl = (record.baseUrl || "https://coursesold.uit.edu.vn").replace(/\/+$/, ""); - const userId = record.userId ? Number.parseInt(String(record.userId), 10) : null; - cfg = { - authType: "token", - token: record.token, - baseUrl, - userId: Number.isFinite(userId) ? userId : null - }; - return cfg; - } + // Use a saved legacy browser session if no account was explicitly selected. + const record = (sessions.legacy || []).find((item) => item.authType === "session" && item.sesskey && Array.isArray(item.cookies) && item.cookies.length); + if (record) { + cfg = { + authType: "session", + baseUrl: record.baseUrl.replace(/\/+$/, ""), + userId: Number(record.userId), + sesskey: record.sesskey, + cookies: record.cookies + }; + return cfg; } throw new CliError("No active UIT session found. Run: uit login (SSO) or uit login --legacy"); @@ -152,48 +169,45 @@ export function getActiveConfig(options: { fresh?: boolean } = {}): Readonly item.baseUrl !== cleanBaseUrl); - legacyList.unshift({ baseUrl: cleanBaseUrl, userId, token }); - sessions.legacy = legacyList; - sessions.active = { authType: "token", baseUrl: cleanBaseUrl }; + sessions.sso = sessionData; + sessions.active = { authType: "sso", baseUrl: sessionData.baseUrl.replace(/\/+$/, "") }; writeSessionsFile(sessions); const path = getSessionsFilePath(); - console.error(`Saved to ${path}`); + console.error(`SSO session saved to ${path}`); cfg = { - authType: "token", - token, - userId, - baseUrl: cleanBaseUrl + authType: "sso", + baseUrl: sessionData.baseUrl.replace(/\/+$/, ""), + userId: sessionData.userId, + sesskey: sessionData.sesskey, + cookies: sessionData.cookies }; return path; } -export function saveSsoSession(sessionData: SsoSessionData): string { +export function saveLegacyBrowserSession(sessionData: MoodleBrowserSessionData): string { + const cleanBaseUrl = sessionData.baseUrl.replace(/\/+$/, ""); const sessions = readSessionsFile(); - sessions.sso = sessionData; - sessions.active = { authType: "sso", baseUrl: sessionData.baseUrl.replace(/\/+$/, "") }; + sessions.legacy = (sessions.legacy || []).filter((item) => item.authType === "session" && item.baseUrl !== cleanBaseUrl); + sessions.legacy.unshift({ ...sessionData, baseUrl: cleanBaseUrl, authType: "session" }); + sessions.active = { authType: "session", baseUrl: cleanBaseUrl }; writeSessionsFile(sessions); const path = getSessionsFilePath(); - console.error(`SSO session saved to ${path}`); + console.error(`Legacy Moodle session saved to ${path}`); cfg = { - authType: "sso", - token: "", - baseUrl: sessionData.baseUrl.replace(/\/+$/, ""), + authType: "session", + baseUrl: cleanBaseUrl, userId: sessionData.userId, sesskey: sessionData.sesskey, cookies: sessionData.cookies @@ -202,12 +216,12 @@ export function saveSsoSession(sessionData: SsoSessionData): string { } /** Select an already-persisted account without changing or re-saving credentials. */ -export function activateSession(authType: "token" | "sso", baseUrl: string): void { +export function activateSession(authType: SessionAuthType, baseUrl: string): void { const cleanBaseUrl = baseUrl.replace(/\/+$/, ""); const sessions = readSessionsFile(); const available = authType === "sso" ? sessions.sso?.baseUrl?.replace(/\/+$/, "") === cleanBaseUrl - : (sessions.legacy || []).some((item) => item.baseUrl?.replace(/\/+$/, "") === cleanBaseUrl && Boolean(item.token)); + : (sessions.legacy || []).some((item) => item.baseUrl?.replace(/\/+$/, "") === cleanBaseUrl && item.authType === "session" && Boolean(item.sesskey && item.cookies?.length)); if (!available) throw new CliError("The selected UIT account is no longer saved. Sign in again."); sessions.active = { authType, baseUrl: cleanBaseUrl }; writeSessionsFile(sessions); @@ -227,10 +241,10 @@ export function deleteLegacySession(baseUrl?: string): void { if (baseUrl) { const clean = baseUrl.replace(/\/+$/, ""); sessions.legacy = (sessions.legacy || []).filter((item) => item.baseUrl !== clean); - if (sessions.active?.authType === "token" && sessions.active.baseUrl === clean) delete sessions.active; + if (sessions.active?.authType === "session" && sessions.active.baseUrl === clean) delete sessions.active; } else { sessions.legacy = []; - if (sessions.active?.authType === "token") delete sessions.active; + if (sessions.active?.authType === "session") delete sessions.active; } writeSessionsFile(sessions); cfg = undefined; diff --git a/src/desktop-service.ts b/src/desktop-service.ts index 2df9c5c..cb9fa75 100644 --- a/src/desktop-service.ts +++ b/src/desktop-service.ts @@ -4,37 +4,24 @@ import { createHash, randomUUID } from "node:crypto"; import { createInflateRaw } from "node:zlib"; import { homedir } from "node:os"; import { basename, dirname, extname, join, relative, resolve, sep } from "node:path"; -import { createTokenApiClient, credentialFreeUrl, defaultApiClient, MAX_PREVIEW_BYTES } from "./api.js"; +import { credentialFreeUrl, defaultApiClient, MAX_PREVIEW_BYTES } from "./api.js"; import { submitAssignmentFile } from "./assignment-submission.js"; -import { activateSession as selectActiveSession, get, save } from "./config.js"; -import { requestMobileToken } from "./commands.js"; +import { activateSession as selectActiveSession, get, type SessionAuthType } from "./config.js"; import { CodexClient } from "./codex-client.js"; import { readH5pActivity, type H5pContentSummary } from "./h5p.js"; import type { ApiClient, MoodleRecord } from "./types.js"; export { calendarMonth, listCalendarEvents, addAssignmentIntervals, calendarReminders } from "./calendar.js"; -export interface DesktopLoginInput { - username: string; - password: string; - baseUrl?: string; -} - export const CURRENT_SITE_BASE_URL = "https://courses.uit.edu.vn"; export interface DesktopSession { authenticated: boolean; - authMode?: "token" | "sso"; + authMode?: SessionAuthType; baseUrl?: string; userId?: number | null; } -export interface DesktopLoginResult { - session: DesktopSession; - api: ApiClient; - token?: string; -} - -export function activateSession(authMode: "token" | "sso", baseUrl: string): void { +export function activateSession(authMode: "sso" | "session", baseUrl: string): void { selectActiveSession(authMode, baseUrl); } @@ -47,7 +34,7 @@ export interface CourseSummary { startdate?: number; enddate?: number; baseUrl?: string; - authMode?: "token" | "sso"; + authMode?: SessionAuthType; siteLabel?: string; discoveredVia?: "url"; category?: { id?: number; name?: string }; @@ -141,7 +128,7 @@ function metadata(api: ApiClient, name: string, params: Record): // The default CLI client follows persisted configuration, unlike desktop session clients. let identity = ""; if (api === defaultApiClient) { - try { identity = createHash("sha256").update(`${get("baseUrl")}:${get("userId")}:${get("token")}`).digest("hex"); } + try { identity = createHash("sha256").update(JSON.stringify([get("baseUrl"), get("userId"), get("authType"), get("sesskey"), get("cookies")])).digest("hex"); } catch { /* The API reports missing CLI configuration when the call executes. */ } } const key = JSON.stringify([identity, name, params]); @@ -282,62 +269,13 @@ function unavailableFrom(value: unknown): Record | undefined { export function sessionStatus(): DesktopSession { try { const baseUrl = get("baseUrl").replace(/\/+$/, ""); - if (baseUrl === CURRENT_SITE_BASE_URL) { - return { authenticated: false, authMode: "sso", baseUrl, userId: get("userId") }; - } - return { authenticated: Boolean(get("token")), authMode: "token", baseUrl, userId: get("userId") }; + const authMode = get("authType"); + return { authenticated: Boolean(get("sesskey") && get("cookies")?.length), authMode, baseUrl, userId: get("userId") }; } catch { return { authenticated: false }; } } -export async function loginWithToken(input: DesktopLoginInput, persist = false): Promise { - const baseUrl = (input.baseUrl || "https://courses.uit.edu.vn").replace(/\/+$/, ""); - if (baseUrl === CURRENT_SITE_BASE_URL) { - throw new Error("The current UIT course site requires UIT SSO. Use the SSO sign-in button."); - } - const token = await requestMobileToken(baseUrl, input.username, input.password); - const url = new URL(`${baseUrl}/webservice/rest/server.php`); - url.searchParams.set("wstoken", token); - url.searchParams.set("wsfunction", "core_webservice_get_site_info"); - url.searchParams.set("moodlewsrestformat", "json"); - const response = await fetch(url, { signal: AbortSignal.timeout(15_000) }); - if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`); - const info = (await response.json()) as MoodleRecord; - if (info.exception) throw new Error(String(info.message || "UIT authentication failed")); - const userId = Number(info.userid); - if (!Number.isInteger(userId) || userId <= 0) throw new Error("UIT did not return a valid student identity."); - if (persist) save(token, userId, baseUrl); - return { - session: { authenticated: true, authMode: "token", baseUrl, userId }, - api: createTokenApiClient(baseUrl, token), - token - }; -} - -export function createLegacySession(baseUrl: string, token: string, userId: number): DesktopLoginResult { - return { - session: { authenticated: true, authMode: "token", baseUrl, userId }, - api: createTokenApiClient(baseUrl, token), - token - }; -} - -export async function login(input: DesktopLoginInput): Promise { - return (await loginWithToken(input, true)).session; -} - -export function configuredLegacySession(): DesktopLoginResult | undefined { - try { - const session = sessionStatus(); - if (!session.authenticated || session.authMode !== "token" || !session.baseUrl) return undefined; - const token = get("token"); - return { session, api: createTokenApiClient(session.baseUrl, token), token }; - } catch { - return undefined; - } -} - function mapCourse(course: MoodleRecord): CourseSummary { return { id: Number(course.id), diff --git a/src/mcp-server.ts b/src/mcp-server.ts index d7a63ff..4583ab8 100644 --- a/src/mcp-server.ts +++ b/src/mcp-server.ts @@ -18,7 +18,7 @@ import { import { randomUUID } from "node:crypto"; import { fileURLToPath } from "node:url"; import type { ApiClient } from "./types.js"; -import { createTokenApiClient, createSessionApiClient } from "./api.js"; +import { createSessionApiClient } from "./api.js"; import { getActiveConfig } from "./config.js"; import { createUitToolExecutor, UIT_ASSIGNMENT_SUBMISSION_TOOL, UIT_TOOLS, type UitToolServices } from "./uit-tools.js"; import * as desktopService from "./desktop-service.js"; @@ -56,19 +56,11 @@ export function resolveAvailableSession(cwd: string = process.cwd()): { api: Api const config = getActiveConfig({ fresh: true }); const userId = Number(config.userId); if (!Number.isSafeInteger(userId) || userId <= 0) { - throw new Error("The active UIT session has no valid user ID. Sign in again or set UIT_USER_ID."); + throw new Error("The active UIT session has no valid user ID. Sign in again."); } - if (config.authType === "sso") { - if (!config.sesskey || !config.cookies) throw new Error("The active UIT SSO session is incomplete. Sign in again."); - return { - api: createSessionApiClient(config.baseUrl, config.sesskey, config.cookies), - userId, - baseUrl: config.baseUrl - }; - } - if (!config.token) throw new Error("The active UIT token session is incomplete. Sign in again."); + if (!config.sesskey || !config.cookies?.length) throw new Error("The active UIT browser session is incomplete. Sign in again."); return { - api: createTokenApiClient(config.baseUrl, config.token), + api: createSessionApiClient(config.baseUrl, config.sesskey, config.cookies), userId, baseUrl: config.baseUrl }; diff --git a/src/studio-sso.ts b/src/moodle-browser-login.ts similarity index 71% rename from src/studio-sso.ts rename to src/moodle-browser-login.ts index 38de3c5..f6bbf6f 100644 --- a/src/studio-sso.ts +++ b/src/moodle-browser-login.ts @@ -4,7 +4,7 @@ import { createRequire } from "node:module"; import { dirname, join, relative, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; import { chromium, type Browser, type BrowserContext, type LaunchOptions } from "playwright"; -import type { SsoSessionData } from "./config.js"; +import type { MoodleBrowserSessionData } from "./config.js"; import { CliError } from "./output.js"; export const PLAYWRIGHT_VERSION = "1.63.0"; @@ -19,36 +19,54 @@ type ChromiumManifest = { executablePath: string; }; -export interface SsoBrowserRuntime { +export interface MoodleBrowserRuntime { executablePath(): string; launch(options?: LaunchOptions): Promise; } -export interface StudioSsoOptions { - runtime?: SsoBrowserRuntime; +export interface MoodleBrowserLoginOptions { + runtime?: MoodleBrowserRuntime; executablePath?: string; timeoutMs?: number; onStatus?: (message: string) => void; } -function normalizeBaseUrl(rawBaseUrl: string): string { +function parseBaseUrl(rawBaseUrl: string): URL { let parsed: URL; try { parsed = new URL(rawBaseUrl); } catch { - throw new CliError("UIT SSO requires a valid HTTPS course-site URL."); + throw new CliError("UIT login requires a valid HTTPS course-site URL."); } - if (parsed.protocol !== "https:" || parsed.hostname !== "courses.uit.edu.vn" || parsed.port || parsed.username || parsed.password || parsed.search || parsed.hash || parsed.pathname !== "/") { + if (parsed.protocol !== "https:" || parsed.port || parsed.username || parsed.password || parsed.search || parsed.hash) { + throw new CliError("UIT login requires an official HTTPS course-site URL without credentials, ports, or query parameters."); + } + return parsed; +} + +function normalizeSsoBaseUrl(rawBaseUrl: string): string { + const parsed = parseBaseUrl(rawBaseUrl); + if (parsed.hostname !== "courses.uit.edu.vn" || parsed.pathname !== "/") { throw new CliError("UIT SSO is available for the current UIT course site only."); } return parsed.origin; } +function normalizeLegacyBaseUrl(rawBaseUrl: string): string { + const parsed = parseBaseUrl(rawBaseUrl); + const pathname = parsed.pathname.replace(/\/+$/, ""); + if (parsed.hostname !== "coursesold.uit.edu.vn" || (pathname !== "" && pathname !== "/sdh")) { + throw new CliError("UIT Legacy login supports the undergraduate portal and the /sdh graduate portal only."); + } + return `${parsed.origin}${pathname}`; +} + function allowedNavigation(rawUrl: string, baseUrl: string): boolean { try { const target = new URL(rawUrl); const base = new URL(baseUrl); - return target.protocol === "https:" && (target.hostname === base.hostname || SSO_ALLOWED_HOSTS.has(target.hostname)); + const ssoRedirect = base.hostname === "courses.uit.edu.vn" && SSO_ALLOWED_HOSTS.has(target.hostname); + return target.protocol === "https:" && (target.hostname === base.hostname || ssoRedirect); } catch { return false; } @@ -66,11 +84,11 @@ function manifestPath(): string { return join(browserDirectory(), BROWSER_MANIFEST); } -function developmentExecutablePath(runtime: SsoBrowserRuntime): string | undefined { +function developmentExecutablePath(runtime: MoodleBrowserRuntime): string | undefined { // A source checkout may use the developer's Playwright cache for local tests // and `npm run dev`. Published packages and native artifacts must provide the // manifest below, so they never silently use that cache. - return existsSync(join(packageRoot(), "src", "studio-sso.ts")) ? runtime.executablePath() : undefined; + return existsSync(join(packageRoot(), "src", "moodle-browser-login.ts")) ? runtime.executablePath() : undefined; } function manifestExecutablePath(): string | undefined { @@ -94,7 +112,7 @@ function manifestExecutablePath(): string | undefined { return executablePath; } -function bundledExecutablePath(runtime: SsoBrowserRuntime, configured?: string): string { +function bundledExecutablePath(runtime: MoodleBrowserRuntime, configured?: string): string { const explicit = configured || process.env.UIT_STUDIO_CHROMIUM_EXECUTABLE; const executablePath = explicit || manifestExecutablePath() || developmentExecutablePath(runtime); if (executablePath && existsSync(executablePath)) return executablePath; @@ -132,7 +150,7 @@ function writeChromiumManifest(executablePath: string): void { writeFileSync(manifestPath(), `${JSON.stringify(manifest, null, 2)}\n`, { encoding: "utf8", mode: 0o644 }); } -function sessionCookies(context: BrowserContext, baseUrl: string): Promise { +function sessionCookies(context: BrowserContext, baseUrl: string): Promise { return context.cookies(baseUrl).then((cookies) => cookies.map((cookie) => ({ name: cookie.name, value: cookie.value, @@ -143,12 +161,38 @@ function sessionCookies(context: BrowserContext, baseUrl: string): Promise 0 ? userId : 0; + } catch { + return 0; + } +} + function readIdentity(page: { evaluate(pageFunction: () => T): Promise }): Promise<{ sesskey: string; userId: number } | null> { return page.evaluate(() => { - const cfg = (globalThis as { M?: { cfg?: { sesskey?: unknown; userId?: unknown; userid?: unknown } } }).M?.cfg || {}; - const sesskey = String(cfg.sesskey || ""); - const userId = Number(cfg.userId || cfg.userid || 0); - return sesskey && Number.isInteger(userId) && userId > 0 ? { sesskey, userId } : null; + const cfg = (globalThis as { M?: { cfg?: { sesskey?: unknown } } }).M?.cfg || {}; + const loginInfo = document.querySelector(".logininfo"); + return { + sesskey: String(cfg.sesskey || ""), + origin: location.origin, + profileHref: loginInfo?.querySelector('a[href*="/user/profile.php"]')?.href || null + }; + }).then((snapshot) => { + const data = snapshot as MoodleIdentitySnapshot; + const sesskey = data.sesskey; + const userId = profileUserId(data.profileHref, data.origin); + return sesskey && Number.isSafeInteger(userId) && userId > 0 ? { sesskey, userId } : null; }).catch(() => null); } @@ -157,23 +201,30 @@ function readIdentity(page: { evaluate(pageFunction: () => T): Promise }): * shipped with the package. The context is intentionally ephemeral: only the * Moodle cookies, sesskey, and account ID leave the authentication browser. */ -export class StudioSsoService { - private readonly runtime: SsoBrowserRuntime; +export class MoodleBrowserLoginService { + private readonly runtime: MoodleBrowserRuntime; private readonly executablePath?: string; private readonly timeoutMs: number; private readonly onStatus?: (message: string) => void; private activeBrowser?: Browser; - constructor(options: StudioSsoOptions = {}) { + constructor(options: MoodleBrowserLoginOptions = {}) { this.runtime = options.runtime || chromium; this.executablePath = options.executablePath; this.timeoutMs = options.timeoutMs || DEFAULT_TIMEOUT_MS; this.onStatus = options.onStatus; } - async login(rawBaseUrl: string): Promise { - const baseUrl = normalizeBaseUrl(rawBaseUrl); - if (this.activeBrowser) throw new CliError("UIT SSO login is already in progress."); + async login(rawBaseUrl: string): Promise { + return this.loginAt(normalizeSsoBaseUrl(rawBaseUrl), "UIT SSO"); + } + + async loginLegacy(rawBaseUrl: string): Promise { + return this.loginAt(normalizeLegacyBaseUrl(rawBaseUrl), "UIT Legacy"); + } + + private async loginAt(baseUrl: string, portalName: string): Promise { + if (this.activeBrowser) throw new CliError("A UIT browser login is already in progress."); const executablePath = bundledExecutablePath(this.runtime, this.executablePath); const browser = await this.runtime.launch({ @@ -182,7 +233,7 @@ export class StudioSsoService { args: ["--window-size=980,760"] }); this.activeBrowser = browser; - this.onStatus?.("Opening bundled Chromium for UIT SSO login..."); + this.onStatus?.(`Opening bundled Chromium for ${portalName} login...`); try { const context = await browser.newContext({ viewport: { width: 980, height: 760 } }); @@ -194,12 +245,12 @@ export class StudioSsoService { }); const page = await context.newPage(); await page.goto(`${baseUrl}/login/index.php`, { waitUntil: "domcontentloaded", timeout: 60_000 }); - this.onStatus?.("Sign in with your UIT account in the Chromium window."); + this.onStatus?.(`Sign in to ${portalName} in the Chromium window.`); const startedAt = Date.now(); while (Date.now() - startedAt <= this.timeoutMs) { if (page.isClosed() || !browser.isConnected()) { - throw new CliError("UIT SSO login window was closed before login completed."); + throw new CliError(`${portalName} login window was closed before login completed.`); } let currentUrl = ""; try { currentUrl = page.url(); } catch { /* The page may be closing during a redirect. */ } @@ -217,7 +268,7 @@ export class StudioSsoService { } await new Promise((resolve) => setTimeout(resolve, 250)); } - throw new CliError("UIT SSO login timed out. Please try again."); + throw new CliError(`${portalName} login timed out. Please try again.`); } finally { await browser.close().catch(() => undefined); this.activeBrowser = undefined; diff --git a/src/moodle-session-client.ts b/src/moodle-session-client.ts index 8a5978b..d0c5c80 100644 --- a/src/moodle-session-client.ts +++ b/src/moodle-session-client.ts @@ -1,4 +1,4 @@ -import { fetchCourseFile, readCourseFile, writeCourseFile } from "./api.js"; +import { fetchCourseFile, readCourseFile, resolveMoodleUrl, writeCourseFile } from "./api.js"; import type { ApiClient, MoodleRecord } from "./types.js"; export interface BrowserSessionTransport { @@ -66,7 +66,7 @@ export class MoodleSessionApi implements ApiClient { } private async pageQuery(path: string, mapper: string): Promise { - const url = new URL(path, this.baseUrl).toString(); + const url = resolveMoodleUrl(this.baseUrl, path).toString(); if (new URL(url).origin !== new URL(this.baseUrl).origin) throw new Error("Course page belongs to another origin."); const script = `(async()=>{const pageUrl=${JSON.stringify(url)};const response=await fetch(pageUrl,{credentials:"include",redirect:"error",signal:AbortSignal.timeout(30000)});if(!response.ok)throw new Error("HTTP "+response.status+": "+response.statusText);if(!/^(text\\/html|application\\/xhtml\\+xml)(;|$)/i.test(response.headers.get('content-type')||'')||/attachment/i.test(response.headers.get('content-disposition')||'')){await response.body?.cancel();throw new Error('Expected a Moodle HTML page, not a download.');}const html=await response.text();const doc=new DOMParser().parseFromString(html,"text/html");if(doc.querySelector('input[name="logintoken"],input[type="password"]'))throw new Error("UIT session expired. Please sign in again.");if(doc.querySelector('.errorbox,[data-rel="fatalerror"]'))throw new Error('Moodle could not display this page.');return (${mapper})(doc,pageUrl);})()`; return await this.transport.execute(script) as T; @@ -175,7 +175,7 @@ export class MoodleSessionApi implements ApiClient { if(value>0)result[field]=value; } const grader=links.find((url)=>url.pathname.endsWith('/mod/assign/view.php')&&Number(url.searchParams.get('id'))===${Number(module.id)}&&url.searchParams.get('action')==='grader'); - if(modname==='assign'&&!result.instance&&grader)result.graderUrl=new URL('/mod/assign/view.php?id='+${Number(module.id)}+'&action=grader',pageUrl).toString(); + if(modname==='assign'&&!result.instance&&grader){const prefix=new URL(pageUrl).pathname.match(/^\/sdh(?:\/|$)/)?'/sdh':'';result.graderUrl=new URL(prefix+'/mod/assign/view.php?id='+${Number(module.id)}+'&action=grader',pageUrl).toString();} return result; }`); // The grader app exposes data-assignmentid. Only visit an existing read-only @@ -241,7 +241,8 @@ export class MoodleSessionApi implements ApiClient { const times=Array.from(row.querySelectorAll('time[data-timestamp]')).map((time)=>Number(time.dataset.timestamp)); const replies=row.querySelector('.replies a,.replies,td.text-center span'); const count=Number(replies?.textContent?.trim()); - return {discussion,name:link?.getAttribute('title')||link?.textContent?.trim()||'',url:new URL('/mod/forum/discuss.php?d='+discussion,pageUrl).toString(),userfullname:row.querySelector('.author .author-info > div,.author a[href*="/user/"]')?.textContent?.trim(),...(times[0]?{created:times[0]}:{}),...(times.length?{timemodified:times[times.length-1]}:{}),...(replies&&Number.isFinite(count)?{numreplies:count}:{})}; + const prefix=new URL(pageUrl).pathname.match(/^\/sdh(?:\/|$)/)?'/sdh':''; + return {discussion,name:link?.getAttribute('title')||link?.textContent?.trim()||'',url:new URL(prefix+'/mod/forum/discuss.php?d='+discussion,pageUrl).toString(),userfullname:row.querySelector('.author .author-info > div,.author a[href*="/user/"]')?.textContent?.trim(),...(times[0]?{created:times[0]}:{}),...(times.length?{timemodified:times[times.length-1]}:{}),...(replies&&Number.isFinite(count)?{numreplies:count}:{})}; }).filter(Boolean); }`); for (let start = 0; start < discussions.length; start += 4) { diff --git a/src/session-health.ts b/src/session-health.ts index bdd18ae..4b77135 100644 --- a/src/session-health.ts +++ b/src/session-health.ts @@ -11,8 +11,6 @@ const authenticationErrorCodes = new Set([ "servicerequireslogin", "invalidsesskey", "notloggedin", - "invalidtoken", - "tokenexpired", "sessionexpired", "sessionnotauthenticated", "notauthenticated", @@ -29,7 +27,7 @@ function isAuthenticationError(value: unknown): boolean { const item = record(value); const code = String(item?.errorcode || "").toLowerCase().replace(/[\s_-]+/g, ""); if (authenticationErrorCodes.has(code)) return true; - return /(?:session|token|sesskey|authentication)\s+(?:is\s+)?(?:expired|invalid|failed)|(?:session|token|sesskey)\s+(?:has\s+)?expired|(?:not\s+authenticated|not\s+logged\s+in)|(?:sign|log)\s+in\s+again|(?:requires?|needs?)\s+(?:a\s+)?login|invalid\s+(?:session|token|sesskey)|phiên\s+đăng\s+nhập\s+đã\s+(?:hết\s+hạn|đăng\s+xuất)|dịch\s+vụ\s+web\s+không\s+tồn\s+tại|token\s+không\s+(?:hợp\s+lệ|được\s+tìm\s+thấy)/i.test(String(item?.message || value)); + return /(?:session|sesskey|authentication)\s+(?:is\s+)?(?:expired|invalid|failed)|(?:session|sesskey)\s+(?:has\s+)?expired|(?:not\s+authenticated|not\s+logged\s+in)|(?:sign|log)\s+in\s+again|(?:requires?|needs?)\s+(?:a\s+)?login|invalid\s+(?:session|sesskey)|phiên\s+đăng\s+nhập\s+đã\s+(?:hết\s+hạn|đăng\s+xuất)/i.test(String(item?.message || value)); } /** Classify a failed live account request without exposing provider error text to the UI. */ diff --git a/src/sso-login.ts b/src/sso-login.ts index 827c099..e1f6399 100644 --- a/src/sso-login.ts +++ b/src/sso-login.ts @@ -1,23 +1,34 @@ -import { saveSsoSession, type SsoSessionData } from "./config.js"; -import { StudioSsoService } from "./studio-sso.js"; +import { saveLegacyBrowserSession, saveSsoSession, type MoodleBrowserSessionData } from "./config.js"; +import { MoodleBrowserLoginService } from "./moodle-browser-login.js"; import { out, loading } from "./output.js"; -export type SsoLoginLauncher = (baseUrl: string) => Promise; +export type SsoLoginLauncher = (baseUrl: string) => Promise; -export async function defaultSsoLauncher(baseUrl: string): Promise { - loading("Opening browser for UIT SSO login..."); - const service = new StudioSsoService({ +async function defaultBrowserLogin(baseUrl: string, authType: "sso" | "legacy"): Promise { + const portalName = authType === "sso" ? "UIT SSO" : "UIT Legacy"; + loading(`Opening browser for ${portalName} login...`); + const service = new MoodleBrowserLoginService({ onStatus: (message) => { if (!message.startsWith("Opening bundled Chromium")) console.error(message); } }); - return service.login(baseUrl); + return authType === "sso" ? service.login(baseUrl) : service.loginLegacy(baseUrl); +} + +export function defaultSsoLauncher(baseUrl: string): Promise { + return defaultBrowserLogin(baseUrl, "sso"); +} + +export type LegacyLoginLauncher = (baseUrl: string) => Promise; + +export function defaultLegacyLauncher(baseUrl: string): Promise { + return defaultBrowserLogin(baseUrl, "legacy"); } export async function cmdLoginSso( args: { url?: string }, launcher: SsoLoginLauncher = defaultSsoLauncher -): Promise { +): Promise { const baseUrl = (args.url || "https://courses.uit.edu.vn").replace(/\/+$/, ""); const sessionData = await launcher(baseUrl); @@ -33,3 +44,22 @@ export async function cmdLoginSso( console.error(`\n✓ Successfully signed in via SSO as user ID ${sessionData.userId}.`); return sessionData; } + +export async function cmdLoginLegacy( + args: { url: string }, + launcher: LegacyLoginLauncher = defaultLegacyLauncher +): Promise { + const baseUrl = args.url.replace(/\/+$/, ""); + const sessionData = await launcher(baseUrl); + saveLegacyBrowserSession(sessionData); + + out({ + status: "ok", + auth: "legacy-session", + user_id: sessionData.userId, + site: baseUrl + }); + + console.error(`\n✓ Successfully signed in to UIT Legacy as user ID ${sessionData.userId}.`); + return sessionData; +} diff --git a/src/studio-core.ts b/src/studio-core.ts index 4efd499..87ef1b1 100644 --- a/src/studio-core.ts +++ b/src/studio-core.ts @@ -6,7 +6,7 @@ import { lstat, mkdir, readFile, readdir, realpath, rename, stat, writeFile } fr import { homedir } from "node:os"; import { dirname, join, relative, resolve, sep } from "node:path"; import type { ApiClient } from "./types.js"; -import type { SsoSessionData } from "./config.js"; +import { readSessionsFile, resetConfigCache, writeSessionsFile, type LegacySessionData, type MoodleBrowserSessionData, type MoodleSessionCookie, type SessionsData } from "./config.js"; import { isCodexThreadNotFoundError, type CodexJsonValue, @@ -28,17 +28,20 @@ import { readStudioThreadStore, writeStudioThreadStore } from "./studio-thread-s import { UIT_ASSIGNMENT_SUBMISSION_TOOL } from "./uit-tools.js"; type JsonRecord = Record; -export interface StudioSsoResult { - session: SsoSessionData; +export interface StudioBrowserLoginResult { + session: MoodleBrowserSessionData; api: ApiClient; } export interface StudioHost { readonly userDataPath: string; /** Authenticate in the package-owned Playwright Chromium context. */ - ssoLogin: (baseUrl: string) => Promise; + ssoLogin: (baseUrl: string) => Promise; + /** Authenticate a legacy Moodle portal in the same managed Chromium context. */ + legacyLogin: (baseUrl: string) => Promise; /** Restore the shared session store without opening an authentication browser. */ - restoreSsoSession: (session: SsoSessionData) => Promise; + restoreSsoSession: (session: MoodleBrowserSessionData) => Promise; + restoreLegacySession: (session: MoodleBrowserSessionData) => Promise; /** Cancel an active authentication browser and optionally clear its storage. */ clearSsoBrowserData: (options: { clearStorage: boolean }) => Promise; ensureMcpConfig(): Promise; @@ -53,7 +56,7 @@ type CourseReference = { courseId: number; baseUrl?: string; userId?: number }; type ConnectedCourse = CourseSummary & { baseUrl: string; userId: number; - authMode: "token" | "sso"; + authMode: "sso" | "session"; siteLabel: string; discoveredVia?: "url"; }; @@ -64,10 +67,11 @@ type AuthenticatedCourseSession = { baseUrl: string; userId: number; api: ApiClient; - authMode: "token" | "sso"; - token?: string; + authMode: "sso" | "session"; + sesskey?: string; + cookies?: MoodleSessionCookie[]; }; -type SsoSession = Omit & { sesskey: string }; +type SsoSession = Omit & { authMode: "sso"; sesskey: string; cookies: MoodleSessionCookie[] }; type ThreadBinding = CourseReference & { baseUrl: string; userId: number; @@ -198,6 +202,8 @@ let codex!: CodexClient; let ssoSession: SsoSession | undefined; let webSsoLoginPromise: Promise | undefined; let webSsoLoginId: symbol | undefined; +const legacyBrowserLoginPromises = new Map>(); +const legacyBrowserLoginIds = new Map(); const legacySessions = new Map(); const threadBindings = new Map(); const approvals = new Map(); @@ -217,7 +223,6 @@ let studioLifecycleWrite = Promise.resolve(); let studioTurnInterruption: Promise | undefined; let studioLifecycleClosed = false; let idleLockTimer: NodeJS.Timeout | undefined; -const SESSIONS_FILE = join(homedir(), ".uit", "sessions.json"); const LINKED_COURSES_STORE_VERSION = 2; const CURRENT_SITE_BASE_URL = "https://courses.uit.edu.vn"; @@ -257,16 +262,6 @@ async function loadService() { // Startup should not make the Studio unavailable. Starting a thread does // require this preflight and will surface an actionable error instead. } - const configured = process.env.UIT_DISABLE_CONFIG === "1" ? undefined : service.configuredLegacySession?.(); - if (configured?.session?.baseUrl && typeof configured.session.userId === "number") { - legacySessions.set(configured.session.baseUrl, { - baseUrl: configured.session.baseUrl, - userId: configured.session.userId, - authMode: "token", - api: configured.api, - token: configured.token - }); - } await restorePersistedLegacySessions(); await restorePersistedSsoSession(); await restorePersistedThreadBindings(); @@ -615,43 +610,27 @@ function requireCourseFileUrl(value: unknown, baseUrl: string): string { return fileUrl.toString(); } -async function readPersistedSessions(): Promise { - try { - const raw = JSON.parse(await readFile(SESSIONS_FILE, "utf8")); - if (raw && typeof raw === "object" && !Array.isArray(raw)) return raw; - return {}; - } catch { - return {}; - } +function readPersistedSessions(): SessionsData { + return readSessionsFile(); } -async function writePersistedSessions(data: JsonRecord): Promise { - if (process.env.UIT_DISABLE_CONFIG === "1") return; - try { - const dir = join(homedir(), ".uit"); - await mkdir(dir, { recursive: true }); - await writeFile(`${SESSIONS_FILE}.part`, JSON.stringify(data, null, 2), { mode: 0o600 }); - await rename(`${SESSIONS_FILE}.part`, SESSIONS_FILE); - } catch (error) { - console.error("Could not persist sessions:", errorMessage(error)); - } +function writePersistedSessions(data: SessionsData): void { + writeSessionsFile(data); + resetConfigCache(); } -async function persistSsoSession(sessionData: JsonRecord): Promise { +async function persistSsoSession(sessionData: MoodleBrowserSessionData): Promise { if (process.env.UIT_DISABLE_CONFIG === "1") return; - try { - const data = await readPersistedSessions(); - data.sso = sessionData; - await writePersistedSessions(data); - } catch (error) { - console.error("Could not persist SSO session:", errorMessage(error)); - } + const data = readPersistedSessions(); + data.sso = sessionData; + data.active = { authType: "sso", baseUrl: sessionData.baseUrl }; + writePersistedSessions(data); } -function normalizeSsoSessionData(value: unknown, expectedBaseUrl?: string): SsoSessionData { - if (!isRecord(value)) throw new Error("The SSO provider returned an invalid session."); +function normalizeBrowserSessionData(value: unknown, expectedBaseUrl?: string): MoodleBrowserSessionData { + if (!isRecord(value)) throw new Error("The Moodle browser login returned an invalid session."); const baseUrl = normalizeSiteUrl(value.baseUrl); - if (expectedBaseUrl && baseUrl !== expectedBaseUrl) throw new Error("The SSO provider returned a different course site."); + if (expectedBaseUrl && baseUrl !== expectedBaseUrl) throw new Error("The Moodle browser login returned a different course site."); const userId = Number(value.userId); const sesskey = typeof value.sesskey === "string" ? value.sesskey : ""; const cookies = Array.isArray(value.cookies) @@ -662,33 +641,41 @@ function normalizeSsoSessionData(value: unknown, expectedBaseUrl?: string): SsoS ...(typeof cookie.path === "string" ? { path: cookie.path } : {}), ...(typeof cookie.secure === "boolean" ? { secure: cookie.secure } : {}), ...(typeof cookie.httpOnly === "boolean" ? { httpOnly: cookie.httpOnly } : {}) - })).filter((cookie) => cookie.name.length > 0 && cookie.value.length > 0) + })).filter((cookie) => /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(cookie.name) && cookie.value.length > 0 && !/[;\r\n]/.test(cookie.value)) : []; if (!Number.isSafeInteger(userId) || userId <= 0 || !sesskey || cookies.length === 0) { - throw new Error("The SSO provider returned an incomplete session."); + throw new Error("The Moodle browser login returned an incomplete session."); } return { baseUrl, userId, sesskey, cookies, savedAt: Date.now() }; } -function normalizeSsoResult(value: unknown, expectedBaseUrl?: string): StudioSsoResult { +function normalizeBrowserLoginResult(value: unknown, expectedBaseUrl?: string): StudioBrowserLoginResult { if (!isRecord(value) || !isRecord(value.api) || typeof value.api.call !== "function") { - throw new Error("The SSO provider did not return a usable course API."); + throw new Error("The Moodle browser login did not return a usable course API."); } return { - session: normalizeSsoSessionData(value.session, expectedBaseUrl), + session: normalizeBrowserSessionData(value.session, expectedBaseUrl), api: value.api as ApiClient }; } -async function installSsoResult(result: StudioSsoResult, expectedBaseUrl: string): Promise { - const normalized = normalizeSsoResult(result, expectedBaseUrl); +async function installSsoResult(result: StudioBrowserLoginResult, expectedBaseUrl: string, persist = true): Promise { + const normalized = normalizeBrowserLoginResult(result, expectedBaseUrl); + const previous = ssoSession; ssoSession = { baseUrl: normalized.session.baseUrl, userId: normalized.session.userId, + authMode: "sso", sesskey: normalized.session.sesskey, + cookies: normalized.session.cookies, api: normalized.api }; - await persistSsoSession(normalized.session); + try { + if (persist) await persistSsoSession(normalized.session); + } catch (error) { + ssoSession = previous; + throw error; + } return { authenticated: true, authMode: "sso", @@ -697,30 +684,57 @@ async function installSsoResult(result: StudioSsoResult, expectedBaseUrl: string }; } +async function installLegacyBrowserResult(result: StudioBrowserLoginResult, expectedBaseUrl: string): Promise { + const normalized = normalizeBrowserLoginResult(result, expectedBaseUrl); + const session: AuthenticatedCourseSession = { + baseUrl: normalized.session.baseUrl, + userId: normalized.session.userId, + authMode: "session", + sesskey: normalized.session.sesskey, + cookies: normalized.session.cookies, + api: normalized.api + }; + const previous = legacySessions.get(session.baseUrl); + legacySessions.set(session.baseUrl, session); + try { + await persistLegacySessions(session.baseUrl); + } catch (error) { + if (previous) legacySessions.set(session.baseUrl, previous); + else legacySessions.delete(session.baseUrl); + throw error; + } + await disconnectAccount(session.baseUrl); + return { authenticated: true, authMode: "session", baseUrl: session.baseUrl, userId: session.userId }; +} + async function deletePersistedSsoSession() { if (process.env.UIT_DISABLE_CONFIG === "1") return; - try { - const data = await readPersistedSessions(); - delete data.sso; - await writePersistedSessions(data); - } catch { /* A missing persisted SSO session is harmless. */ } + const data = readPersistedSessions(); + delete data.sso; + if (data.active?.authType === "sso") delete data.active; + writePersistedSessions(data); } -async function persistLegacySessions(): Promise { +async function persistLegacySessions(activeBaseUrl?: string): Promise { if (process.env.UIT_DISABLE_CONFIG === "1") return; - try { - const records = []; - for (const session of legacySessions.values()) { - if (session.baseUrl && session.userId && session.token) { - records.push({ baseUrl: session.baseUrl, userId: session.userId, token: session.token }); - } + const records: LegacySessionData[] = []; + for (const session of legacySessions.values()) { + if (session.baseUrl && session.userId && session.authMode === "session" && session.sesskey && session.cookies?.length) { + records.push({ + authType: "session", + baseUrl: session.baseUrl, + userId: session.userId, + sesskey: session.sesskey, + cookies: session.cookies + }); } - const data = await readPersistedSessions(); - data.legacy = records; - await writePersistedSessions(data); - } catch (error) { - console.error("Could not persist legacy sessions:", errorMessage(error)); } + const data = readPersistedSessions(); + data.legacy = records; + const activeRecord = activeBaseUrl ? records.find((record) => record.baseUrl === activeBaseUrl) : undefined; + if (activeRecord) data.active = { authType: "session", baseUrl: activeRecord.baseUrl }; + else if (data.active?.authType === "session" && !records.some((record) => record.baseUrl === data.active?.baseUrl)) delete data.active; + writePersistedSessions(data); } async function restorePersistedLegacySessions() { @@ -729,16 +743,20 @@ async function restorePersistedLegacySessions() { const data = await readPersistedSessions(); if (Array.isArray(data.legacy)) { for (const item of data.legacy) { - if (item && item.baseUrl && item.token && item.userId) { + if (item && item.baseUrl && item.userId) { const baseUrl = normalizeSiteUrl(item.baseUrl); - if (!isCurrentSite(baseUrl) && service.createLegacySession) { - const restored = service.createLegacySession(baseUrl, item.token, Number(item.userId)); + if (isCurrentSite(baseUrl)) continue; + if (item.authType === "session" && item.sesskey && Array.isArray(item.cookies)) { + const saved = normalizeBrowserSessionData(item, baseUrl); + const restored = await host.restoreLegacySession(saved); + if (!restored || restored.session.userId !== saved.userId) continue; legacySessions.set(baseUrl, { baseUrl, - userId: Number(item.userId), - authMode: "token", - api: restored.api, - token: item.token + userId: saved.userId, + authMode: "session", + sesskey: saved.sesskey, + cookies: saved.cookies, + api: restored.api }); } } @@ -756,11 +774,11 @@ async function restorePersistedSsoSession() { const saved = data?.sso; if (!saved || !saved.baseUrl || !saved.userId || !saved.sesskey) return; - const savedSession = normalizeSsoSessionData(saved); + const savedSession = normalizeBrowserSessionData(saved); const restored = await host.restoreSsoSession(savedSession); if (!restored) return; if (restored.session.userId !== savedSession.userId) throw new Error("The restored SSO account did not match the saved account."); - await installSsoResult(restored, savedSession.baseUrl); + await installSsoResult(restored, CURRENT_SITE_BASE_URL, false); } catch (error) { console.error("Could not auto-restore SSO session:", errorMessage(error)); } @@ -970,6 +988,8 @@ async function readThreadRollout(threadId: string, afterMtime = 0): Promise { webSsoLoginId = undefined; webSsoLoginPromise = undefined; + legacyBrowserLoginIds.clear(); + legacyBrowserLoginPromises.clear(); ssoSession = undefined; await host.clearSsoBrowserData({ clearStorage }); if (clearStorage) { @@ -1003,6 +1023,29 @@ async function startSsoLogin(rawBaseUrl: unknown, forceReauthentication = false) return promise; } +async function startLegacyBrowserLogin(rawBaseUrl: unknown): Promise { + const baseUrl = normalizeSiteUrl(rawBaseUrl); + if (isCurrentSite(baseUrl)) throw new Error("The current UIT course site requires UIT SSO."); + const pending = legacyBrowserLoginPromises.get(baseUrl); + if (pending) return pending; + + const loginId = Symbol("legacy-browser-login"); + const promise = (async () => { + const result = await host.legacyLogin(baseUrl); + if (legacyBrowserLoginIds.get(baseUrl) !== loginId) throw new Error("UIT Legacy login was cancelled."); + return installLegacyBrowserResult(result, baseUrl); + })(); + promise.finally(() => { + if (legacyBrowserLoginIds.get(baseUrl) === loginId) { + legacyBrowserLoginIds.delete(baseUrl); + legacyBrowserLoginPromises.delete(baseUrl); + } + }).catch(() => undefined); + legacyBrowserLoginIds.set(baseUrl, loginId); + legacyBrowserLoginPromises.set(baseUrl, promise); + return promise; +} + async function verifiedCourse(rawInput: unknown): Promise { const reference = courseSession(rawInput); const key = JSON.stringify([reference.session.baseUrl, reference.session.userId, reference.courseId]); @@ -1460,15 +1503,9 @@ export function createStudioHandlers(): Record { }, "session:login": async (rawInput) => { const input = requireObject(rawInput, "Login input"); - const baseUrl = normalizeSiteUrl(input?.baseUrl || CURRENT_SITE_BASE_URL); + const baseUrl = normalizeSiteUrl(requireString(input.baseUrl, "Course site")); if (isCurrentSite(baseUrl)) throw new Error("The current UIT course site requires UIT SSO. Use the SSO sign-in button."); - const username = requireString(input.username, "Student ID"); - const password = requireString(input.password, "Password"); - const result = await service.loginWithToken({ username, password, baseUrl }, false); - await disconnectAccount(baseUrl); - if (typeof result.session.userId !== "number") throw new Error("The legacy UIT account did not return a valid account ID."); - legacySessions.set(baseUrl, { baseUrl, userId: result.session.userId, authMode: "token", api: result.api, token: result.token }); - await persistLegacySessions(); + await startLegacyBrowserLogin(baseUrl); return sessionStatusPayload(); }, "session:sso-login": async (rawInput) => { @@ -1480,16 +1517,35 @@ export function createStudioHandlers(): Record { }, "session:logout": async (rawInput) => { const input = rawInput === undefined || rawInput === null ? {} : requireObject(rawInput, "Logout input"); - await disconnectAccount(input.baseUrl ? normalizeSiteUrl(input.baseUrl) : undefined); + const onlyLegacy = input.legacy === true; + const targetBaseUrl = input.baseUrl ? normalizeSiteUrl(input.baseUrl) : undefined; + if (targetBaseUrl && legacyBrowserLoginIds.has(targetBaseUrl)) { + legacyBrowserLoginIds.delete(targetBaseUrl); + legacyBrowserLoginPromises.delete(targetBaseUrl); + await host.clearSsoBrowserData({ clearStorage: false }); + } + if (onlyLegacy && !targetBaseUrl) { + const pendingBaseUrls = [...legacyBrowserLoginIds.keys()]; + legacyBrowserLoginIds.clear(); + legacyBrowserLoginPromises.clear(); + if (pendingBaseUrls.length) await host.clearSsoBrowserData({ clearStorage: false }); + for (const baseUrl of legacySessions.keys()) await disconnectAccount(baseUrl); + } else { + await disconnectAccount(targetBaseUrl); + } if (input.baseUrl) { - const baseUrl = normalizeSiteUrl(requireString(input.baseUrl, "Course site")); + const baseUrl = targetBaseUrl!; if (isCurrentSite(baseUrl)) await clearSsoSession({ clearStorage: true }); else legacySessions.delete(baseUrl); + } else if (onlyLegacy) { + legacySessions.clear(); + await persistLegacySessions(); } else { await clearSsoSession({ clearStorage: true }); legacySessions.clear(); + await persistLegacySessions(); } - await persistLegacySessions(); + if (input.baseUrl && !isCurrentSite(targetBaseUrl!)) await persistLegacySessions(); return sessionStatusPayload(); }, "courses:list": listConnectedCourses, diff --git a/src/studio-web-server.ts b/src/studio-web-server.ts index c75c949..801035a 100644 --- a/src/studio-web-server.ts +++ b/src/studio-web-server.ts @@ -18,14 +18,14 @@ import { realpathSync } from "node:fs"; import { dirname, extname, join, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; import { createSessionApiClient } from "./api.js"; -import type { SsoSessionData } from "./config.js"; +import type { MoodleBrowserSessionData } from "./config.js"; import { createStudioCore, type StudioCore, type StudioHandler, type StudioHost } from "./studio-core.js"; -import { StudioSsoService } from "./studio-sso.js"; +import { MoodleBrowserLoginService } from "./moodle-browser-login.js"; type JsonRecord = Record; @@ -352,16 +352,18 @@ export function createStudioWebHost(options: { platform?: NodeJS.Platform; }): StudioHost { const platform = options.platform || process.platform; - const ssoService = new StudioSsoService(); - const sessionResult = (session: SsoSessionData) => ({ + const browserLoginService = new MoodleBrowserLoginService(); + const sessionResult = (session: MoodleBrowserSessionData) => ({ session, api: createSessionApiClient(session.baseUrl, session.sesskey, session.cookies) }); return { userDataPath: options.userDataPath, - ssoLogin: async (baseUrl) => sessionResult(await ssoService.login(baseUrl)), + ssoLogin: async (baseUrl) => sessionResult(await browserLoginService.login(baseUrl)), + legacyLogin: async (baseUrl) => sessionResult(await browserLoginService.loginLegacy(baseUrl)), restoreSsoSession: async (session) => session.cookies.length > 0 ? sessionResult(session) : null, - clearSsoBrowserData: async (_options) => ssoService.cancel(), + restoreLegacySession: async (session) => session.cookies.length > 0 ? sessionResult(session) : null, + clearSsoBrowserData: async (_options) => browserLoginService.cancel(), ensureMcpConfig: async () => { if (process.env.UIT_DISABLE_CONFIG === "1") return; const mcp = await import("./mcp-server.js"); diff --git a/studio/renderer/index.html b/studio/renderer/index.html index d26b1ce..3c84126 100644 --- a/studio/renderer/index.html +++ b/studio/renderer/index.html @@ -198,7 +198,7 @@

Codex

- Student ID + UIT Legacy coursesold.uit.edu.vn
Not connected @@ -210,9 +210,8 @@

Codex

- - - +

A Chromium window will open for you to sign in. UIT Studio stores the Moodle session, not your password.

+
diff --git a/studio/renderer/renderer.js b/studio/renderer/renderer.js index 07227d8..84d193d 100644 --- a/studio/renderer/renderer.js +++ b/studio/renderer/renderer.js @@ -3216,7 +3216,7 @@ function aggregateSessionHealth(sessions) { } function sessionDisplayName(session) { if (!session) return "UIT course account"; - return session.baseUrl === CURRENT_SITE || session.authMode === "sso" ? "UIT SSO" : portalKind(session.baseUrl) === "Graduate" ? "Graduate Moodle" : "Student ID"; + return session.baseUrl === CURRENT_SITE || session.authMode === "sso" ? "UIT SSO" : portalKind(session.baseUrl) + " UIT Legacy"; } function sessionForPortalError(entry) { return state.sessions.find((session) => String(session.baseUrl).replace(/\/+$/, "") === String(entry?.baseUrl || "").replace(/\/+$/, "")); @@ -3246,7 +3246,7 @@ function portalNotice(status) { const action = session && ["expired", "unavailable"].includes(healthState) && (session.authMode === "sso" || session.baseUrl === CURRENT_SITE) ? { kind: "account", label: "Sign in again with UIT SSO", run: async () => { await authAction(() => window.uit.session.ssoLogin({ baseUrl: session.baseUrl }), "UIT SSO connected."); } } : session && ["expired", "unavailable"].includes(healthState) - ? { kind: "account", label: "Sign in again with UIT Legacy", run: () => openLogin({ legacy: true }) } + ? { kind: "account", label: "Sign in again with UIT Legacy (" + portalKind(session.baseUrl).toLowerCase() + ")", run: () => openLogin({ legacy: true, baseUrl: session.baseUrl }) } : { kind: "account", label: "Open Course accounts", run: openLogin }; if (!actionLabels.has(action.label)) { actionLabels.add(action.label); actions.push(action); } } @@ -3329,10 +3329,14 @@ function renderSessions() { } function openLogin(options = {}) { state.loginFormOpen = options?.legacy === true; + if (state.loginFormOpen) { + const preferredBaseUrl = options.baseUrl || state.sessions.find((session) => session.baseUrl !== CURRENT_SITE)?.baseUrl; + if (preferredBaseUrl) $("#course-site").value = preferredBaseUrl; + } $("#login-error").textContent = ""; $("#login-status").textContent = ""; renderSessions(); if (!$("#login-modal").open) $("#login-modal").showModal(); - if (state.loginFormOpen) $("#login-form input[name='username']").focus(); + if (state.loginFormOpen) $("#course-site").focus(); window.uit.session.status().then(renderDiscovery).catch(() => { $("#discovery-report").textContent = "Could not read discovery diagnostics."; }); } async function authAction(action, success) { @@ -3663,18 +3667,17 @@ $("#sso-disconnect").addEventListener("click", () => { const currentSession = state.sessions.find((s) => s.baseUrl === CURRENT_SITE); if (currentSession) authAction(() => window.uit.session.logout({ baseUrl: currentSession.baseUrl }), "UIT SSO disconnected."); }); -$("#legacy-relogin").addEventListener("click", () => openLogin({ legacy: true })); -$("#legacy-disconnect").addEventListener("click", () => { - const legacy = state.sessions.find((s) => s.baseUrl !== CURRENT_SITE); - if (legacy) authAction(() => window.uit.session.logout({ baseUrl: legacy.baseUrl }), "Student ID disconnected."); +$("#legacy-relogin").addEventListener("click", () => { + const legacy = state.sessions.find((session) => session.baseUrl !== CURRENT_SITE && ["expired", "unavailable"].includes(sessionHealthState(session))) || state.sessions.find((session) => session.baseUrl !== CURRENT_SITE); + openLogin({ legacy: true, baseUrl: legacy?.baseUrl }); }); +$("#legacy-disconnect").addEventListener("click", () => authAction(() => window.uit.session.logout({ legacy: true }), "UIT Legacy disconnected.")); $("#login-form").addEventListener("submit", async (event) => { event.preventDefault(); const form = event.currentTarget; const fields = new FormData(form); - const input = { username: fields.get("username"), password: fields.get("password"), baseUrl: fields.get("baseUrl") }; - try { await authAction(() => window.uit.session.login(input), "Student ID login connected."); } - finally { form.elements.password.value = ""; input.password = ""; } + const input = { baseUrl: fields.get("baseUrl") }; + await authAction(() => window.uit.session.login(input), "UIT Legacy connected."); }); $("#logout-button").addEventListener("click", () => authAction(() => window.uit.session.logout(), "All portals disconnected. Local threads will be available when the same accounts reconnect.")); $("#agent-messages").addEventListener("click", (event) => { diff --git a/test/cli.test.ts b/test/cli.test.ts index e5c9308..e38a65f 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -3,18 +3,25 @@ import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createProgram, main } from "../src/cli.js"; -import { resetConfigCache } from "../src/config.js"; +import { resetConfigCache, saveSsoSession } from "../src/config.js"; import type { ApiClient } from "../src/types.js"; import { VERSION } from "../src/version.js"; import { makeZip } from "./zip-fixture.js"; const originalCwd = process.cwd(); +const testState = vi.hoisted(() => ({ home: "" })); let tempDir: string; let stdoutSpy: ReturnType; let stderrSpy: ReturnType; let stdoutWriteSpy: ReturnType; let stdout = ""; let stderr = ""; +let originalUitToken: string | undefined; + +vi.mock("node:os", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, homedir: () => testState.home || actual.homedir() }; +}); function mockApi(responses: Record): ApiClient { return { @@ -28,12 +35,18 @@ function mockApi(responses: Record): ApiClient { } beforeEach(() => { + originalUitToken = process.env.UIT_TOKEN; + delete process.env.UIT_TOKEN; tempDir = mkdtempSync(join(tmpdir(), "uit-cli-test-")); - process.env.UIT_TOKEN = "token-123"; - process.env.UIT_BASE_URL = "https://courses.uit.edu.vn"; - process.env.UIT_USER_ID = "42"; + testState.home = tempDir; process.chdir(tempDir); resetConfigCache(); + saveSsoSession({ + baseUrl: "https://courses.uit.edu.vn", + userId: 42, + sesskey: "test-session-key", + cookies: [{ name: "MoodleSession", value: "test-session-cookie" }] + }); stdout = ""; stderr = ""; stdoutWriteSpy = vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { @@ -49,14 +62,14 @@ beforeEach(() => { }); afterEach(() => { - delete process.env.UIT_TOKEN; - delete process.env.UIT_BASE_URL; - delete process.env.UIT_USER_ID; stdoutSpy.mockRestore(); stderrSpy.mockRestore(); stdoutWriteSpy.mockRestore(); process.chdir(originalCwd); resetConfigCache(); + if (originalUitToken === undefined) delete process.env.UIT_TOKEN; + else process.env.UIT_TOKEN = originalUitToken; + testState.home = ""; rmSync(tempDir, { recursive: true, force: true }); }); diff --git a/test/course-resources.test.ts b/test/course-resources.test.ts index 3a39ce7..b1eee9d 100644 --- a/test/course-resources.test.ts +++ b/test/course-resources.test.ts @@ -3,7 +3,7 @@ import { mkdir, readFile, readdir, rename, rm, symlink, writeFile } from "node:f import { createHash } from "node:crypto"; import { basename, dirname, join } from "node:path"; import { deflateRawSync } from "node:zlib"; -import { createTokenApiClient, credentialFreeUrl, fetchCourseFile, MAX_PREVIEW_BYTES, readCourseFile } from "../src/api.js"; +import { credentialFreeUrl, createSessionApiClient, fetchCourseFile, MAX_PREVIEW_BYTES, readCourseFile } from "../src/api.js"; import { clearCourseCache, courseWorkspace, getAssignmentSubmission, getCourseContents, listAnnouncements, listAssignments, listCourses, listForumDiscussions, materializeCourseFile, materializeFile, previewableMime, previewFile, resolveClassCodeSemesters, resolveCourseFile, resolveCourseResource } from "../src/desktop-service.js"; import type { ApiClient, MoodleRecord } from "../src/types.js"; import { makeZip } from "./zip-fixture.js"; @@ -591,7 +591,7 @@ describe("in-memory preview", () => { }); }); -describe("authenticated token files", () => { +describe("authenticated browser-session files", () => { it.each([ [`https://user:password@courses.uit.edu.vn/pluginfile.php/1/a.pdf?TOKEN=one&token=two&wstoken=three&sesskey=four&authkey=five&key=six&file=%2F1%2Fa.pdf`, `${site}/pluginfile.php/1/a.pdf?file=%2F1%2Fa.pdf`], ["javascript:alert(1)", undefined], @@ -601,49 +601,28 @@ describe("authenticated token files", () => { expect(credentialFreeUrl(input)).toBe(expected); }); - it("uses cleaned service metadata with each client's own token", async () => { - const signed = `${site}/sdh/tokenpluginfile.php/signedkey/1/mod_resource/content/2/a.pdf?forcedownload=1`; - const fetchMock = vi.fn(async (input: string | URL) => { - const url = new URL(input); - if (url.pathname.endsWith("/server.php")) return Response.json([{ modules: [{ id: 10, contents: [{ ...file, fileurl: signed }] }] }]); - return new Response("hello", { headers: { "content-type": "application/pdf" } }); - }); + it("removes Moodle URL signatures before fetching through the browser session", async () => { + const signed = `${site}/sdh/tokenpluginfile.php/signedkey/1/mod_resource/content/2/a.pdf?token=secret&forcedownload=1`; + const fetchMock = vi.fn().mockResolvedValue(new Response("hello", { headers: { "content-type": "application/pdf" } })); vi.stubGlobal("fetch", fetchMock); - for (const token of ["first-mobile", "second-mobile"]) { - const api = createTokenApiClient(`${site}/sdh`, token); - const [module] = await getCourseContents(42, api); - const clean = module.files[0].fileurl; - expect(clean).toBe(`${site}/sdh/pluginfile.php/1/mod_resource/content/2/a.pdf?forcedownload=1`); - await resolveCourseResource(42, { kind: "file", id: 10, fileUrl: clean }, api); - await previewFile(42, clean, "ignored", api); - const requested = new URL(fetchMock.mock.calls.at(-1)![0]); - expect(requested.pathname).toBe("/sdh/webservice/pluginfile.php/1/mod_resource/content/2/a.pdf"); - expect(requested.searchParams.get("token")).toBe(token); - expect(requested.href).not.toContain("signedkey"); - } + + await fetchCourseFile(`${site}/sdh`, signed, { Cookie: "MoodleSession=session-cookie" }); + + const [requested, options] = fetchMock.mock.calls[0]; + const url = new URL(requested); + expect(url.pathname).toBe("/sdh/pluginfile.php/1/mod_resource/content/2/a.pdf"); + expect(url.search).toBe("?forcedownload=1"); + expect(options.headers).toEqual({ Cookie: "MoodleSession=session-cookie" }); }); it("preserves the graduate installation and query across relative redirects", async () => { const fetchMock = vi.fn().mockResolvedValueOnce(new Response(null, { status: 302, headers: { location: "?file=%2F1%2Fa.pdf&forcedownload=1" } })) .mockResolvedValueOnce(new Response("hello", { headers: { "content-type": "application/pdf" } })); vi.stubGlobal("fetch", fetchMock); - await createTokenApiClient(`${site}/sdh`, "mobile").readFile!("pluginfile.php?file=%2F1%2Fa.pdf"); - expect(String(fetchMock.mock.calls[1][0])).toBe(`${site}/sdh/webservice/pluginfile.php?file=%2F1%2Fa.pdf&forcedownload=1&token=mobile`); - }); - - it.each([ - ["/pluginfile.php/1/mod_resource/content/2/a%20b.pdf?forcedownload=1", "/webservice/pluginfile.php/1/mod_resource/content/2/a%20b.pdf?forcedownload=1&token=mobile"], - ["/webservice/pluginfile.php/1/a.pdf?token=old", "/webservice/pluginfile.php/1/a.pdf?token=mobile"], - ["/pluginfile.php?file=%2F1%2Fa%20b.pdf&forcedownload=1", "/webservice/pluginfile.php?file=%2F1%2Fa+b.pdf&forcedownload=1&token=mobile"], - ["/tokenpluginfile.php/signedkey/1/mod_resource/content/2/a.pdf?forcedownload=1", "/webservice/pluginfile.php/1/mod_resource/content/2/a.pdf?forcedownload=1&token=mobile"], - ["/tokenpluginfile.php?file=%2F1%2Fa.pdf&token=signedkey", "/webservice/pluginfile.php?file=%2F1%2Fa.pdf&token=mobile"] - ])("normalizes supported file endpoint %s with installation prefixes", async (input, expected) => { - for (const prefix of ["", "/sdh"]) { - const fetchMock = vi.fn().mockResolvedValue(new Response("hello", { headers: { "content-type": "text/plain" } })); - vi.stubGlobal("fetch", fetchMock); - await createTokenApiClient(`${site}${prefix}/`, "mobile").readFile!(`${site}${prefix}${input}`); - expect(String(fetchMock.mock.calls[0][0])).toBe(`${site}${prefix}${expected}`); - } + await fetchCourseFile(`${site}/sdh`, "pluginfile.php?file=%2F1%2Fa.pdf", { Cookie: "MoodleSession=session-cookie" }); + expect(String(fetchMock.mock.calls[0][0])).toBe(`${site}/sdh/pluginfile.php?file=%2F1%2Fa.pdf`); + expect(String(fetchMock.mock.calls[1][0])).toBe(`${site}/sdh/pluginfile.php?file=%2F1%2Fa.pdf&forcedownload=1`); + expect(fetchMock.mock.calls[1][1].headers).toEqual({ Cookie: "MoodleSession=session-cookie" }); }); it("normalizes signed URLs to credential-free URLs usable with session cookies", async () => { @@ -659,22 +638,16 @@ describe("authenticated token files", () => { expect(fetchMock.mock.calls[0][1].headers).toEqual({ Cookie: "MoodleSession=secret" }); }); - it.each(["/mod/resource/view.php?id=1", "/pluginfile.php.evil/1/a.pdf", "/other/pluginfile.php/1/a.pdf", "/tokenpluginfile.php/bad"])("does not attach mobile tokens to unsupported endpoints %s", async (path) => { - const fetchMock = vi.fn(); - vi.stubGlobal("fetch", fetchMock); - await expect(createTokenApiClient(`${site}/sdh`, "secret").readFile!(`${site}${path}`)).rejects.toThrow(/Unsupported/); - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it("authenticates same-origin redirects without leaking tokens", async () => { + it("streams downloads with the Moodle session cookie and follows same-origin redirects", async () => { const fetchMock = vi.fn().mockResolvedValueOnce(new Response(null, { status: 302, headers: { location: "/pluginfile.php/final" } })) .mockResolvedValueOnce(new Response("hello", { headers: { "content-type": "text/plain; charset=utf-8" } })); vi.stubGlobal("fetch", fetchMock); - const api = createTokenApiClient(site, "secret"); + const api = createSessionApiClient(site, "sesskey", [{ name: "MoodleSession", value: "session-cookie" }]); expect(Buffer.from((await api.readFile!(file.fileurl)).data).toString()).toBe("hello"); for (const [url, options] of fetchMock.mock.calls) { - expect(new URL(url).searchParams.get("token")).toBe("secret"); - expect(new URL(url).pathname).toMatch(/^\/webservice\/pluginfile\.php\//); + expect(new URL(url).searchParams.has("token")).toBe(false); + expect(new URL(url).pathname).toMatch(/^\/pluginfile\.php\//); + expect(options.headers).toEqual({ Cookie: "MoodleSession=session-cookie" }); expect(options.redirect).toBe("manual"); } await expect(api.readFile!("https://evil.example/file")).rejects.toThrow("another origin"); @@ -683,13 +656,13 @@ describe("authenticated token files", () => { it.each(["text/html", "application/octet-stream"])("rejects login HTML labeled %s", async (mimeType) => { vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response('
', { headers: { "content-type": mimeType } }))); - await expect(createTokenApiClient(site, "secret").readFile!(file.fileurl)).rejects.toThrow("login page"); + await expect(fetchCourseFile(site, file.fileurl, { Cookie: "MoodleSession=session-cookie" })).rejects.toThrow("login page"); }); it.each(["https://evil.example/file", "/login/index.php"])("rejects redirect to %s before sending credentials", async (location) => { const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 302, headers: { location } })); vi.stubGlobal("fetch", fetchMock); - await expect(createTokenApiClient(site, "secret").readFile!(file.fileurl)).rejects.toThrow(); + await expect(fetchCourseFile(site, file.fileurl, { Cookie: "MoodleSession=session-cookie" })).rejects.toThrow(); expect(fetchMock).toHaveBeenCalledOnce(); }); }); @@ -896,24 +869,15 @@ describe("deterministic materialization", () => { expect(api.downloadFile).toHaveBeenLastCalledWith(file.fileurl, expect.any(String), { atomic: false }); }); - it("streams a production token-client download into the pinned destination", async () => { - await home(); - vi.stubGlobal("fetch", vi.fn() - .mockResolvedValueOnce(new Response(JSON.stringify([{ modules: [{ id: 10, contents: [file] }] }]))) - .mockResolvedValueOnce(new Response(JSON.stringify([{ id: 7, username: "23521146", fullname: "Nguyễn Thuận Phát" }]))) - .mockResolvedValueOnce(new Response("from-production-client"))); - const identity = { baseUrl: site, userId: 7, shortname: "CS" }; - const destination = await materializeFile(42, file.fileurl, file.filename, createTokenApiClient(site, "secret"), identity); - expect(await readFile(destination, "utf8")).toBe("from-production-client"); - }); - - it("uses the documented token endpoint for explicit downloads", async () => { + it("downloads through the normal Moodle pluginfile endpoint", async () => { await home(); const fetchMock = vi.fn().mockResolvedValue(new Response("complete")); vi.stubGlobal("fetch", fetchMock); const destination = join(state.home, "download.pdf"); - await createTokenApiClient(`${site}/sdh`, "mobile").downloadFile(`${site}/sdh/pluginfile.php/1/a.pdf?forcedownload=1`, destination); - expect(String(fetchMock.mock.calls[0][0])).toBe(`${site}/sdh/webservice/pluginfile.php/1/a.pdf?forcedownload=1&token=mobile`); + const api = createSessionApiClient(`${site}/sdh`, "sesskey", [{ name: "MoodleSession", value: "session-cookie" }]); + await api.downloadFile(`${site}/sdh/webservice/pluginfile.php/1/a.pdf?forcedownload=1&token=obsolete`, destination); + expect(String(fetchMock.mock.calls[0][0])).toBe(`${site}/sdh/pluginfile.php/1/a.pdf?forcedownload=1`); + expect(fetchMock.mock.calls[0][1].headers).toEqual({ Cookie: "MoodleSession=session-cookie" }); expect(await readFile(destination, "utf8")).toBe("complete"); }); }); diff --git a/test/desktop-service.test.ts b/test/desktop-service.test.ts index 79bfe01..e08a7f1 100644 --- a/test/desktop-service.test.ts +++ b/test/desktop-service.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { defaultApiClient } from "../src/api.js"; -import { clearCourseCache, codexStatus, getCourseContents, getCourseGrades, listAnnouncements, listAssignments, listCourseParticipants, listCourses, login, lookupCourse, readParticipantAvatar, sessionStatus } from "../src/desktop-service.js"; +import { clearCourseCache, codexStatus, getCourseContents, getCourseGrades, listAnnouncements, listAssignments, listCourseParticipants, listCourses, lookupCourse, readParticipantAvatar, sessionStatus } from "../src/desktop-service.js"; import type { ApiClient } from "../src/types.js"; const codexProbe = vi.hoisted(() => ({ connect: vi.fn(), readAccount: vi.fn(), disconnect: vi.fn() })); @@ -87,10 +87,6 @@ describe("desktop service", () => { expect(codexProbe.disconnect).toHaveBeenCalledTimes(2); }); - it("rejects password/token login for the current SSO-only site", async () => { - await expect(login({ username: "student", password: "not-used", baseUrl: "https://courses.uit.edu.vn" })).rejects.toThrow("requires UIT SSO"); - }); - it("normalizes assignments from the Moodle response", async () => { const call = vi.spyOn(defaultApiClient, "call").mockResolvedValue({ courses: [{ id: 42, assignments: [{ id: 7, cmid: 8, name: "Project", intro: "

Build it

", duedate: 1_800_000_000 }] }] diff --git a/test/fixtures/studio.ts b/test/fixtures/studio.ts index f54f933..8b63553 100644 --- a/test/fixtures/studio.ts +++ b/test/fixtures/studio.ts @@ -35,7 +35,7 @@ export const fileTypes = [ ]; const sessions = [ { baseUrl: CURRENT, userId: 101, authMode: "sso", label: "Current Moodle" }, - { baseUrl: LEGACY, userId: 202, authMode: "token", label: "Legacy Moodle" }, + { baseUrl: LEGACY, userId: 202, authMode: "session", label: "Legacy Moodle" }, ]; type SessionHealth = "connected" | "expired" | "unavailable"; @@ -52,6 +52,7 @@ function installBridge(seed: { courses: typeof courses; files: typeof fileTypes; const held = new Set(); const pending: { method: string; input: any; resolve: (value: any) => void }[] = []; const failures = { ...seed.options.fail }; + let nextLegacyUserId = 202; // Chromium can briefly expose a browser error document after a transient // navigation failure. Storage access is forbidden on that origin, so keep // the fixture boot script diagnostic-free until the page reaches localhost. @@ -100,11 +101,19 @@ function installBridge(seed: { courses: typeof courses; files: typeof fileTypes; url: `${account.baseUrl}/mod/assign/view.php?id=701`, })), }; - if (method === "session.logout") { connected = input?.baseUrl ? connected.filter((s: any) => s.baseUrl !== input.baseUrl) : []; return save(); } + if (method === "session.logout") { + connected = input?.baseUrl + ? connected.filter((session: any) => session.baseUrl !== input.baseUrl) + : input?.legacy === true + ? connected.filter((session: any) => session.authMode === "sso") + : []; + return save(); + } if (method === "session.ssoLogin" || method === "session.login") { - const userId = method === "session.ssoLogin" ? 101 : Number(input.username); + const userId = method === "session.ssoLogin" ? 101 : nextLegacyUserId; + if (method === "session.login") nextLegacyUserId = 202; connected = connected.filter((s: any) => s.baseUrl !== input.baseUrl); - connected.push({ baseUrl: input.baseUrl, userId, authMode: method === "session.ssoLogin" ? "sso" : "token" }); + connected.push({ baseUrl: input.baseUrl, userId, authMode: method === "session.ssoLogin" ? "sso" : "session" }); return save(); } if (method === "codex.status") return { state: "ready", installed: true, message: "Codex App Server is ready" }; @@ -159,6 +168,7 @@ function installBridge(seed: { courses: typeof courses; files: typeof fileTypes; get threadStoreRaw() { return threadStore; }, hold: (method: string) => held.add(method), fail: (method: string, message: string) => { failures[method] = message; }, + setNextLegacyUserId: (userId: number) => { nextLegacyUserId = userId; }, release: (method: string, index = 0) => { const item = pending.filter((item) => item.method === method)[index]; if (!item) throw new Error(`No pending ${method} at ${index}`); diff --git a/test/helpers.test.ts b/test/helpers.test.ts index 1116f60..e2eaa5f 100644 --- a/test/helpers.test.ts +++ b/test/helpers.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from "node:os"; import { join, sep } from "node:path"; import { describe, expect, it } from "vitest"; import { afterEach, vi } from "vitest"; -import { courseDownloadPath, requestMobileToken } from "../src/commands.js"; +import { courseDownloadPath } from "../src/commands.js"; import { clean, extractUrls, htmlToText, idOrUrl, parseMoodleUrl, sanitize, ts } from "../src/output.js"; import { extractH5pPackage, parseZip, readZipEntry } from "../src/unzip.js"; import { makeZip } from "./zip-fixture.js"; @@ -11,7 +11,6 @@ import { makeZip } from "./zip-fixture.js"; afterEach(() => { vi.unstubAllGlobals(); }); - describe("output helpers", () => { it("parses integer IDs and Moodle URLs", () => { expect(idOrUrl("428837")).toBe(428837); @@ -130,31 +129,3 @@ describe("h5p package extraction", () => { } }); }); - - -describe("Moodle token request", () => { - it("requests a mobile web-service token with form data", async () => { - const fetchMock = vi.fn(async (_url: string, init: RequestInit) => { - const body = init.body as URLSearchParams; - expect(init.method).toBe("POST"); - expect(init.headers).toEqual({ "Content-Type": "application/x-www-form-urlencoded" }); - expect(body.get("username")).toBe("student"); - expect(body.get("password")).toBe("secret"); - expect(body.get("service")).toBe("moodle_mobile_app"); - return Response.json({ token: "mobile-token" }); - }); - vi.stubGlobal("fetch", fetchMock); - - await expect(requestMobileToken("https://courses.uit.edu.vn", "student", "secret")).resolves.toBe("mobile-token"); - expect(fetchMock).toHaveBeenCalledWith( - "https://courses.uit.edu.vn/login/token.php", - expect.objectContaining({ method: "POST" }) - ); - }); - - it("surfaces Moodle login errors", async () => { - vi.stubGlobal("fetch", vi.fn(async () => Response.json({ error: "Invalid login" }))); - - await expect(requestMobileToken("https://courses.uit.edu.vn", "student", "wrong")).rejects.toThrow("Invalid login"); - }); -}); diff --git a/test/session-health.test.ts b/test/session-health.test.ts index 3ea9311..03f985d 100644 --- a/test/session-health.test.ts +++ b/test/session-health.test.ts @@ -12,9 +12,7 @@ describe("session health", () => { "requireloginerror", "servicerequireslogin", "invalidsesskey", - "notloggedin", - "invalidtoken", - "tokenexpired" + "notloggedin" ])("classifies %s as expired", (errorcode) => { expect(classifySessionError(error("provider rejected session", errorcode))).toBe("expired"); }); @@ -24,15 +22,18 @@ describe("session health", () => { }); it("classifies Moodle's Vietnamese expired-session response as expired", () => { - expect(classifySessionError(new Error("Moodle: Dịch vụ web không tồn tại. (Phiên đăng nhập đã hết hạn hoặc đã đăng xuất)."))).toBe("expired"); + expect(classifySessionError(new Error("Phiên đăng nhập đã hết hạn hoặc đã đăng xuất."))).toBe("expired"); }); - it("classifies Moodle's Vietnamese invalid-token response as expired", () => { - expect(classifySessionError(new Error("Token không hợp lệ - token không được tìm thấy"))).toBe("expired"); + it.each([ + "Token không hợp lệ - token không được tìm thấy", + "Dịch vụ web không tồn tại" + ])("does not classify obsolete web-service errors as an expired browser session: %s", (message) => { + expect(classifySessionError(new Error(message))).toBe("unavailable"); }); it("walks wrapped causes", () => { - expect(classifySessionError(error("Moodle request failed", undefined, error("Invalid token", "invalidtoken")))).toBe("expired"); + expect(classifySessionError(error("Moodle request failed", undefined, error("Invalid sesskey", "invalidsesskey")))).toBe("expired"); }); it("keeps access and transport failures unavailable", () => { diff --git a/test/sso-cli.test.ts b/test/sso-cli.test.ts index 038297e..941364d 100644 --- a/test/sso-cli.test.ts +++ b/test/sso-cli.test.ts @@ -16,10 +16,10 @@ vi.mock("node:os", async (importOriginal) => { }); import { createProgram, main } from "../src/cli.js"; -import { get, getActiveConfig, resetConfigCache, save, saveSsoSession, type SsoSessionData } from "../src/config.js"; +import { get, getActiveConfig, resetConfigCache, saveLegacyBrowserSession, saveSsoSession, type MoodleBrowserSessionData } from "../src/config.js"; import { NodeSessionApiClient, createSessionApiClient } from "../src/api.js"; import { defaultSsoLauncher } from "../src/sso-login.js"; -import { StudioSsoService } from "../src/studio-sso.js"; +import { MoodleBrowserLoginService } from "../src/moodle-browser-login.js"; import { workspacePath } from "../src/desktop-service.js"; import { executeMcpTool, resolveAvailableSession } from "../src/mcp-server.js"; import type { ApiClient } from "../src/types.js"; @@ -29,6 +29,7 @@ let tempDir: string; let stdout = ""; let stdoutSpy: ReturnType; let stderrSpy: ReturnType; +let originalUitToken: string | undefined; function mockApi(responses: Record): ApiClient { return { @@ -42,6 +43,8 @@ function mockApi(responses: Record): ApiClient { } beforeEach(() => { + originalUitToken = process.env.UIT_TOKEN; + delete process.env.UIT_TOKEN; tempDir = mkdtempSync(join(tmpdir(), "uit-sso-cli-test-")); testState.home = tempDir; process.chdir(tempDir); @@ -59,12 +62,14 @@ afterEach(() => { stderrSpy.mockRestore(); process.chdir(originalCwd); resetConfigCache(); + if (originalUitToken === undefined) delete process.env.UIT_TOKEN; + else process.env.UIT_TOKEN = originalUitToken; rmSync(tempDir, { recursive: true, force: true }); }); describe("SSO CLI workflow and session resolution", () => { it("saves SSO session and updates active config", () => { - const ssoData: SsoSessionData = { + const ssoData: MoodleBrowserSessionData = { baseUrl: "https://courses.uit.edu.vn", userId: 19589, sesskey: "sesskey-12345", @@ -78,7 +83,6 @@ describe("SSO CLI workflow and session resolution", () => { expect(get("userId")).toBe(19589); expect(get("sesskey")).toBe("sesskey-12345"); expect(get("cookies")).toEqual([{ name: "MoodleSession", value: "cookie-value-abc" }]); - expect(get("token")).toBe(""); const configDir = join(tempDir, ".uit"); expect(readdirSync(configDir)).toEqual(["sessions.json"]); if (process.platform !== "win32") { @@ -86,7 +90,7 @@ describe("SSO CLI workflow and session resolution", () => { } }); - it("uses environment credentials consistently when they override a saved session", () => { + it("rejects obsolete web-service tokens instead of silently selecting a saved session", () => { saveSsoSession({ baseUrl: "https://courses.uit.edu.vn", userId: 19589, @@ -94,41 +98,42 @@ describe("SSO CLI workflow and session resolution", () => { cookies: [{ name: "MoodleSession", value: "saved-cookie" }] }); process.env.UIT_TOKEN = "environment-token"; - process.env.UIT_BASE_URL = "https://coursesold.uit.edu.vn/"; - process.env.UIT_USER_ID = "42"; try { - expect(getActiveConfig({ fresh: true })).toMatchObject({ - authType: "token", - baseUrl: "https://coursesold.uit.edu.vn", - token: "environment-token", - userId: 42 - }); + expect(() => getActiveConfig({ fresh: true })).toThrow("UIT_TOKEN authentication is no longer supported"); } finally { delete process.env.UIT_TOKEN; - delete process.env.UIT_BASE_URL; - delete process.env.UIT_USER_ID; } }); - it("makes an explicit token login active when an SSO session already exists", () => { + it("makes a legacy browser session active when an SSO session already exists", () => { saveSsoSession({ baseUrl: "https://courses.uit.edu.vn", userId: 19589, sesskey: "saved-sesskey", cookies: [{ name: "MoodleSession", value: "saved-cookie" }] }); - save("replacement-token", 42, "https://courses.uit.edu.vn"); + saveLegacyBrowserSession({ + baseUrl: "https://coursesold.uit.edu.vn", + userId: 42, + sesskey: "legacy-sesskey", + cookies: [{ name: "MoodleSession", value: "legacy-cookie" }] + }); expect(getActiveConfig({ fresh: true })).toMatchObject({ - authType: "token", - baseUrl: "https://courses.uit.edu.vn", - token: "replacement-token", + authType: "session", + baseUrl: "https://coursesold.uit.edu.vn", + sesskey: "legacy-sesskey", userId: 42 }); }); it("uses the active session for every managed course workspace", () => { - save("legacy-token", 77, "https://coursesold.uit.edu.vn"); + saveLegacyBrowserSession({ + baseUrl: "https://coursesold.uit.edu.vn", + userId: 77, + sesskey: "legacy-sesskey", + cookies: [{ name: "MoodleSession", value: "legacy-cookie" }] + }); saveSsoSession({ baseUrl: "https://courses.uit.edu.vn", userId: 19589, @@ -146,7 +151,7 @@ describe("SSO CLI workflow and session resolution", () => { }); }); - it("honors environment credentials in every managed course workspace", () => { + it("does not allow an obsolete web-service token in a managed course workspace", () => { saveSsoSession({ baseUrl: "https://courses.uit.edu.vn", userId: 19589, @@ -154,22 +159,20 @@ describe("SSO CLI workflow and session resolution", () => { cookies: [{ name: "MoodleSession", value: "saved-cookie" }] }); process.env.UIT_TOKEN = "environment-token"; - process.env.UIT_BASE_URL = "https://coursesold.uit.edu.vn"; - process.env.UIT_USER_ID = "42"; try { - expect(resolveAvailableSession(workspacePath(7, "https://courses.uit.edu.vn", 19589))).toMatchObject({ - baseUrl: "https://coursesold.uit.edu.vn", - userId: 42 - }); + expect(() => resolveAvailableSession(workspacePath(7, "https://courses.uit.edu.vn", 19589))).toThrow("UIT_TOKEN authentication is no longer supported"); } finally { delete process.env.UIT_TOKEN; - delete process.env.UIT_BASE_URL; - delete process.env.UIT_USER_ID; } }); it("allows cross-course MCP selection inside the managed workspace", async () => { - save("legacy-token", 77, "https://coursesold.uit.edu.vn"); + saveLegacyBrowserSession({ + baseUrl: "https://coursesold.uit.edu.vn", + userId: 77, + sesskey: "legacy-sesskey", + cookies: [{ name: "MoodleSession", value: "legacy-cookie" }] + }); const workspace = workspacePath(42, "https://coursesold.uit.edu.vn", 77); vi.stubGlobal("fetch", vi.fn(async () => Response.json({ exception: "MoodleException", message: "fixture unavailable" }))); @@ -188,6 +191,23 @@ describe("SSO CLI workflow and session resolution", () => { expect(() => getActiveConfig({ fresh: true })).toThrow("No active UIT session found"); }); + it("does not restore legacy web-service-token records from the canonical session store", () => { + const configDir = join(tempDir, ".uit"); + mkdirSync(configDir, { recursive: true }); + writeFileSync(join(configDir, "sessions.json"), JSON.stringify({ + sso: { + baseUrl: "https://courses.uit.edu.vn", + userId: 99, + sesskey: "sso-sesskey", + cookies: [{ name: "MoodleSession", value: "sso-cookie" }] + }, + legacy: [{ baseUrl: "https://coursesold.uit.edu.vn", userId: 42, token: "old-token" }], + active: { authType: "token", baseUrl: "https://coursesold.uit.edu.vn" } + }), "utf8"); + + expect(() => getActiveConfig({ fresh: true })).toThrow("unsupported authentication method"); + }); + it.each([ ["sessions.json", [{ baseUrl: "https://coursesold.uit.edu.vn", userId: 42, token: "old-token" }]], ["sso-session.json", { baseUrl: "https://courses.uit.edu.vn", userId: 42, sesskey: "old-sesskey", cookies: [] }], @@ -240,51 +260,42 @@ describe("SSO CLI workflow and session resolution", () => { expect(JSON.parse(stdout)).toMatchObject({ status: "ok", auth: "sso", user_id: 2027 }); }); - it("restores uit login --legacy and persists the token from Student ID/password", async () => { - const mockLauncher = vi.fn(async () => { - throw new Error("SSO must not run for legacy login"); - }); - const fetchMock = vi.fn(async (input: string | URL, init?: RequestInit) => { - const url = String(input); - if (url === "https://coursesold.uit.edu.vn/login/token.php") { - expect(init?.method).toBe("POST"); - expect((init?.body as URLSearchParams).get("username")).toBe("2026"); - expect((init?.body as URLSearchParams).get("password")).toBe("legacy-password"); - return Response.json({ token: "legacy-token-2026" }); - } - - const parsed = new URL(url); - expect(parsed.origin).toBe("https://coursesold.uit.edu.vn"); - expect(parsed.pathname).toBe("/webservice/rest/server.php"); - expect(parsed.searchParams.get("wstoken")).toBe("legacy-token-2026"); - return Response.json({ userid: 2026, fullname: "Legacy Student", sitename: "Legacy Moodle" }); - }); - vi.stubGlobal("fetch", fetchMock); - - const program = createProgram(mockApi({}), { ssoLauncher: mockLauncher }); + it.each([ + ["undergraduate", "https://coursesold.uit.edu.vn", false], + ["graduate", "https://coursesold.uit.edu.vn/sdh", true] + ])("signs in to the %s legacy portal in the browser and persists its session", async (_portal, baseUrl, graduate) => { + const mockLauncher = vi.fn(async (site: string) => ({ + baseUrl: site, + userId: 2026, + sesskey: "legacy-sesskey-2026", + cookies: [{ name: "MoodleSession", value: "legacy-cookie-2026" }] + })); + const program = createProgram(mockApi({}), { legacyLauncher: mockLauncher }); await program.parseAsync([ "node", "uit", "--json", "login", "--legacy", - "--username", - "2026", - "--password", - "legacy-password" + ...(graduate ? ["--graduate"] : []) ]); - expect(mockLauncher).not.toHaveBeenCalled(); - expect(fetchMock).toHaveBeenCalledTimes(2); + expect(mockLauncher).toHaveBeenCalledWith(baseUrl); expect(JSON.parse(stdout)).toEqual({ status: "ok", - user: "Legacy Student", + auth: "legacy-session", user_id: 2026, - site: "Legacy Moodle" + site: baseUrl }); expect(JSON.parse(readFileSync(join(tempDir, ".uit", "sessions.json"), "utf8"))).toMatchObject({ - legacy: [{ baseUrl: "https://coursesold.uit.edu.vn", userId: 2026, token: "legacy-token-2026" }], - active: { authType: "token", baseUrl: "https://coursesold.uit.edu.vn" } + legacy: [{ + authType: "session", + baseUrl, + userId: 2026, + sesskey: "legacy-sesskey-2026", + cookies: [{ name: "MoodleSession", value: "legacy-cookie-2026" }] + }], + active: { authType: "session", baseUrl } }); }); @@ -299,7 +310,7 @@ describe("SSO CLI workflow and session resolution", () => { }); it("falls back to SSO session when .env is absent and executes courses", async () => { - const ssoData: SsoSessionData = { + const ssoData: MoodleBrowserSessionData = { baseUrl: "https://courses.uit.edu.vn", userId: 3333, sesskey: "sso-3333", @@ -353,7 +364,11 @@ describe("SSO CLI workflow and session resolution", () => { const mockPage = { goto: vi.fn().mockResolvedValue(undefined), url: vi.fn().mockReturnValue("https://courses.uit.edu.vn/my/"), - evaluate: vi.fn().mockResolvedValue({ sesskey: "sso-key", userId: 99 }), + evaluate: vi.fn().mockResolvedValue({ + sesskey: "sso-key", + origin: "https://courses.uit.edu.vn", + profileHref: "https://courses.uit.edu.vn/user/profile.php?id=99" + }), isClosed: vi.fn().mockReturnValue(false) }; const mockContext = { @@ -390,9 +405,47 @@ describe("SSO CLI workflow and session resolution", () => { } }); + it("opens the graduate legacy portal in bundled Chromium and captures its browser session", async () => { + const baseUrl = "https://coursesold.uit.edu.vn/sdh"; + const mockPage = { + goto: vi.fn().mockResolvedValue(undefined), + url: vi.fn().mockReturnValue(`${baseUrl}/my/`), + evaluate: vi.fn().mockResolvedValue({ + sesskey: "legacy-key", + origin: "https://coursesold.uit.edu.vn", + profileHref: "https://coursesold.uit.edu.vn/sdh/user/profile.php?id=2026" + }), + isClosed: vi.fn().mockReturnValue(false) + }; + const mockContext = { + route: vi.fn().mockResolvedValue(undefined), + newPage: vi.fn().mockResolvedValue(mockPage), + cookies: vi.fn().mockResolvedValue([{ name: "MoodleSession", value: "legacy-cookie", domain: "coursesold.uit.edu.vn", path: "/" }]) + }; + const mockBrowser = { + newContext: vi.fn().mockResolvedValue(mockContext), + isConnected: vi.fn().mockReturnValue(true), + close: vi.fn().mockResolvedValue(undefined) + }; + const launch = vi.fn().mockResolvedValue(mockBrowser); + const service = new MoodleBrowserLoginService({ + executablePath: process.execPath, + runtime: { executablePath: () => process.execPath, launch } + }); + + const session = await service.loginLegacy(baseUrl); + + expect(mockPage.goto).toHaveBeenCalledWith(`${baseUrl}/login/index.php`, { waitUntil: "domcontentloaded", timeout: 60_000 }); + expect(mockContext.cookies).toHaveBeenCalledWith(baseUrl); + expect(session).toMatchObject({ baseUrl, userId: 2026, sesskey: "legacy-key" }); + expect(session.cookies).toHaveLength(1); + expect(launch).toHaveBeenCalledWith(expect.objectContaining({ executablePath: process.execPath, headless: false })); + expect(mockBrowser.close).toHaveBeenCalledOnce(); + }); + it("reports a missing bundled browser instead of trying a system browser", async () => { const launch = vi.fn(); - const service = new StudioSsoService({ + const service = new MoodleBrowserLoginService({ executablePath: join(tempDir, "missing-chromium"), runtime: { executablePath: () => join(tempDir, "unused-chromium"), diff --git a/test/studio-ui.spec.ts b/test/studio-ui.spec.ts index 3a56f73..4bb73f8 100644 --- a/test/studio-ui.spec.ts +++ b/test/studio-ui.spec.ts @@ -702,9 +702,7 @@ for (const selected of ["older", "all"] as const) { window.uit.courses.list = async () => (await list()).map((course: any) => course.baseUrl === legacy && course.semester ? { ...course, semester } : course.baseUrl !== legacy && course.id === 1 ? { ...course, id: 807, shortname: "AI505.R11", fullname: "Khoá luận tốt nghiệp - AI505.R11", semester: undefined } : course); }, { legacy: LEGACY, semester: semesters[1] }); await page.getByRole("button", { name: "Connect UIT account", exact: true }).click(); - await page.getByLabel("Student ID", { exact: true }).fill("202"); - await page.getByLabel("Password", { exact: true }).fill("fixture-only-password"); - await page.getByRole("button", { name: "Connect", exact: true }).click(); + await page.getByRole("button", { name: "Continue in browser" }).click(); await expect(page.locator("#semester-select")).toHaveValue("all"); await expect(page.locator("#session-summary")).toContainText("Account 202"); await page.getByRole("button", { name: "Close accounts" }).click(); @@ -714,7 +712,7 @@ for (const selected of ["older", "all"] as const) { await page.getByRole("button", { name: "Continue with UIT SSO" }).click(); await expect(page.locator("#session-summary .session-row")).toHaveCount(2); await expect(page.locator("#session-summary")).toContainText("UIT SSO"); - await expect(page.locator("#session-summary")).toContainText("Student ID"); + await expect(page.locator("#session-summary")).toContainText("UIT Legacy"); await expect(page.locator("#semester-select")).toHaveValue("all"); await page.getByRole("button", { name: "Close accounts" }).click(); await expect(page.locator(".course-row")).toHaveCount(19); @@ -1267,11 +1265,13 @@ test("disconnect and reconnect isolates threads by portal AND account", async ({ await page.getByRole("button", { name: "Save name" }).click(); await page.locator("#account-button").click(); await page.locator(`.session-row[data-base-url="${LEGACY}"]`).getByRole("button", { name: "Disconnect", exact: true }).click(); + expect((await calls(page, "session.logout")).map((call) => call.input)).toEqual([{ legacy: true }]); + expect((await page.evaluate(() => window.uit.session.status())).sessions.map((session: any) => session.baseUrl)).toEqual([CURRENT]); await expect(page.locator("#account-label")).toHaveText("Course accounts (1)"); - await page.getByLabel("Student ID", { exact: true }).fill("303"); - await page.getByLabel("Password", { exact: true }).fill("fake-password-only"); - await page.getByRole("button", { name: "Connect", exact: true }).click(); + await page.evaluate(() => window.__mock.setNextLegacyUserId(303)); + await page.getByRole("button", { name: "Continue in browser" }).click(); await expect(page.locator("#session-summary")).toContainText("Account 303"); + expect((await calls(page, "session.login"))[0].input).toEqual({ baseUrl: LEGACY }); await page.getByRole("button", { name: "Close accounts" }).click(); await page.locator('.nav-item[data-view="agent"]').click(); await expect(page.locator(".thread-link")).toHaveCount(0); @@ -1290,9 +1290,7 @@ test("disconnect and reconnect isolates threads by portal AND account", async ({ await expect(page.locator("#course-nav .project")).toHaveCount(1); await page.locator("#account-button").click(); await page.locator("#legacy-relogin").click(); - await page.getByLabel("Student ID", { exact: true }).fill("202"); - await page.getByLabel("Password", { exact: true }).fill("fake-password-only"); - await page.getByRole("button", { name: "Connect", exact: true }).click(); + await page.getByRole("button", { name: "Continue in browser" }).click(); await expect(page.locator("#session-summary")).toContainText("Account 202"); await page.getByRole("button", { name: "Close accounts" }).click(); await page.locator(".thread-link").filter({ hasText: "Private legacy thread" }).click(); @@ -1307,40 +1305,32 @@ test("disconnect and reconnect isolates threads by portal AND account", async ({ const stored = await threadStore(page); expect(stored.projects.map((project: any) => project.userId)).toEqual([202, 303]); expect(stored.threads).toHaveLength(1); - expect(JSON.stringify(await threadStore(page))).not.toContain("fake-password-only"); + expect((await calls(page, "session.login")).map((call) => call.input)).toEqual([{ baseUrl: LEGACY }]); }); -test("login form failure, retry, dual session success, password clearing and logout", async ({ page, boot }) => { +test("legacy browser login failure, retry, dual session success and logout", async ({ page, boot }) => { await boot({ authenticated: false }); await page.getByRole("button", { name: "Connect UIT account", exact: true }).click(); await expect(page.getByRole("dialog", { name: "Course accounts", exact: true })).toBeVisible(); - await page.getByRole("button", { name: "Connect", exact: true }).click(); - expect(await calls(page, "session.login")).toHaveLength(0); - await page.getByLabel("Student ID", { exact: true }).fill("202"); - await page.getByLabel("Password", { exact: true }).fill("invalid-fixture-password"); - await control(page, "fail", "session.login", "Fixture: invalid credentials"); - await page.getByRole("button", { name: "Connect", exact: true }).click(); - await expect(page.locator("#login-error")).toContainText("Fixture: invalid credentials"); - await expect(page.getByLabel("Password", { exact: true })).toHaveValue(""); - await expect(page.getByRole("button", { name: "Connect", exact: true })).toBeEnabled(); - await page.getByLabel("Password", { exact: true }).fill("valid-fixture-password"); + await control(page, "fail", "session.login", "Fixture: browser login failed"); + await page.getByRole("button", { name: "Continue in browser" }).click(); + await expect(page.locator("#login-error")).toContainText("Fixture: browser login failed"); + await expect(page.getByRole("button", { name: "Continue in browser" })).toBeEnabled(); await control(page, "hold", "session.login"); - await page.getByRole("button", { name: "Connect", exact: true }).click(); - await expect(page.getByRole("button", { name: "Connect", exact: true })).toBeDisabled(); + await page.getByRole("button", { name: "Continue in browser" }).click(); + await expect(page.getByRole("button", { name: "Continue in browser" })).toBeDisabled(); await expect(page.getByRole("button", { name: "Continue with UIT SSO" })).toBeDisabled(); await control(page, "release", "session.login"); - await expect(page.locator("#login-status")).toContainText("Student ID login connected"); - await expect(page.getByLabel("Password", { exact: true })).toHaveValue(""); + await expect(page.locator("#login-status")).toContainText("UIT Legacy connected."); await page.getByRole("button", { name: "Continue with UIT SSO" }).click(); await expect(page.locator("#session-summary .session-row")).toHaveCount(2); await expect(page.getByRole("button", { name: "Re-login with UIT SSO", exact: true })).toBeVisible(); expect((await calls(page, "session.ssoLogin"))[0].input).toEqual({ baseUrl: CURRENT }); - expect((await calls(page, "session.login"))[1].input).toEqual({ username: "202", password: "valid-fixture-password", baseUrl: LEGACY }); + expect((await calls(page, "session.login")).map((call) => call.input)).toEqual([{ baseUrl: LEGACY }, { baseUrl: LEGACY }]); await page.getByRole("button", { name: "Disconnect all portals" }).click(); await expect(page.locator("#session-summary .session-row")).toHaveCount(0); await page.getByRole("button", { name: "Close accounts" }).click(); await expect(page.getByRole("button", { name: "Connect UIT account", exact: true })).toBeVisible(); - expect(await page.evaluate(() => JSON.stringify(localStorage))).not.toContain("fixture-password"); }); test("Course accounts show live session health and recovery actions", async ({ page, boot }) => { @@ -1369,18 +1359,18 @@ test("expired accounts are excluded from the count and expose inline SSO recover test("account load failures use the shared warning UI and fresh users see onboarding", async ({ page, boot }) => { await boot({ health: { [CURRENT]: "expired", [LEGACY]: "expired" }, - fail: { "courses.list": "Moodle: Dịch vụ web không tồn tại. Legacy Moodle: Token không hợp lệ" } + fail: { "courses.list": "UIT session expired. Legacy Moodle session expired. Backend detail hidden." } }); await expect(page.locator("#app-error")).toContainText("UIT SSO session expired. Sign in again to reconnect."); - await expect(page.locator("#app-error")).toContainText("Student ID session expired. Sign in again to reconnect."); + await expect(page.locator("#app-error")).toContainText("UIT Legacy session expired. Sign in again to reconnect."); await expect(page.getByRole("button", { name: "Sign in again with UIT SSO", exact: true })).toBeVisible(); - await expect(page.getByRole("button", { name: "Sign in again with UIT Legacy", exact: true })).toBeVisible(); - await page.getByRole("button", { name: "Sign in again with UIT Legacy", exact: true }).click(); + await expect(page.getByRole("button", { name: /Sign in again with UIT Legacy/ })).toBeVisible(); + await page.getByRole("button", { name: /Sign in again with UIT Legacy/ }).click(); await expect(page.getByRole("dialog", { name: "Course accounts", exact: true })).toBeVisible(); await expect(page.locator("#login-form")).toBeVisible(); - await expect(page.getByLabel("Student ID", { exact: true })).toBeFocused(); + await expect(page.getByRole("combobox", { name: "Portal", exact: true })).toBeFocused(); await expect(page.locator("#course-grid [role=alert]")).toHaveCount(0); - await expect(page.locator("#course-grid")).not.toContainText("Dịch vụ web không tồn tại"); + await expect(page.locator("#course-grid")).not.toContainText("Backend detail hidden"); }); test("fresh users see onboarding without account failure warnings", async ({ page, boot }) => { @@ -1684,7 +1674,7 @@ test("storage quota failure is visible and a later draft save recovers", async ( await expect(page.getByLabel("Message Codex")).toHaveValue("Recovered draft"); }); -test("SSO cancellation can retry and graduate legacy form sends the selected portal", async ({ page, boot }) => { +test("SSO cancellation can retry and graduate legacy browser login uses the selected portal", async ({ page, boot }) => { await boot({ authenticated: false }); await page.getByRole("button", { name: "Connect UIT account", exact: true }).click(); await control(page, "fail", "session.ssoLogin", "Fixture SSO window was closed"); @@ -1693,15 +1683,13 @@ test("SSO cancellation can retry and graduate legacy form sends the selected por await page.getByRole("button", { name: "Continue with UIT SSO" }).click(); await expect(page.locator("#session-summary")).toContainText("Account 101"); await page.getByRole("combobox", { name: "Portal", exact: true }).selectOption(`${LEGACY}/sdh`); - await page.getByLabel("Student ID", { exact: true }).fill("404"); - await page.getByLabel("Password", { exact: true }).fill("graduate-fixture-password"); - await page.getByRole("button", { name: "Connect", exact: true }).click(); + await page.evaluate(() => window.__mock.setNextLegacyUserId(404)); + await page.getByRole("button", { name: "Continue in browser" }).click(); await expect(page.locator("#session-summary .session-row")).toHaveCount(2); - expect((await calls(page, "session.login"))[0].input).toEqual({ baseUrl: `${LEGACY}/sdh`, username: "404", password: "graduate-fixture-password" }); + expect((await calls(page, "session.login"))[0].input).toEqual({ baseUrl: `${LEGACY}/sdh` }); await page.getByRole("button", { name: "Close accounts" }).click(); await page.locator("#account-button").click(); - await expect(page.getByLabel("Student ID", { exact: true })).toHaveValue(""); - await expect(page.getByLabel("Password", { exact: true })).toHaveValue(""); + await expect(page.locator("#login-form")).toBeHidden(); }); test("completed conversation persists, stream output deduplicates and offline reload never sends", async ({ page, boot }) => { @@ -1900,7 +1888,8 @@ for (const width of [390, 320]) { await expect(page.getByRole("dialog", { name: "Course accounts", exact: true })).toBeVisible(); await noOverflow(); await page.locator("#legacy-relogin").click(); - await expect(page.getByLabel("Password", { exact: true })).toBeVisible(); + await expect(page.locator("#login-form")).toBeVisible(); + await expect(page.getByRole("button", { name: "Continue in browser" })).toBeVisible(); }); } @@ -2147,7 +2136,7 @@ test("dark PDF toolbar and dialog leave the white document canvas and rendered p for (const method of ["courses.materialize", "courses.open", "shell.open"]) expect(await calls(page, method)).toHaveLength(0); }); -test("dark login dialog, native fields and visible credential error remain readable", async ({ page, boot }, info) => { +test("dark login dialog and browser-login error remain readable", async ({ page, boot }, info) => { await page.emulateMedia({ colorScheme: "dark" }); await boot({ authenticated: false }); await page.getByRole("button", { name: "Connect UIT account", exact: true }).click(); @@ -2156,17 +2145,13 @@ test("dark login dialog, native fields and visible credential error remain reada await expect(dialog).toHaveCSS("background-color", "rgb(21, 25, 35)"); await expect(dialog).toHaveCSS("color", "rgb(235, 238, 243)"); await expect(dialog).toHaveCSS("color-scheme", "dark"); - for (const field of [dialog.getByRole("combobox", { name: "Portal", exact: true }), dialog.getByLabel("Student ID", { exact: true }), dialog.getByLabel("Password", { exact: true })]) { - await expect(field).toHaveCSS("background-color", "rgb(21, 25, 35)"); - await expect(field).toHaveCSS("color", "rgb(235, 238, 243)"); - } - await page.getByLabel("Student ID", { exact: true }).fill("202"); - await page.getByLabel("Password", { exact: true }).fill("fixture-only-password"); - await control(page, "fail", "session.login", "Fixture: invalid credentials"); - await page.getByRole("button", { name: "Connect", exact: true }).click(); - await expect(page.locator("#login-error")).toContainText("Fixture: invalid credentials"); + const portal = dialog.getByRole("combobox", { name: "Portal", exact: true }); + await expect(portal).toHaveCSS("background-color", "rgb(21, 25, 35)"); + await expect(portal).toHaveCSS("color", "rgb(235, 238, 243)"); + await control(page, "fail", "session.login", "Fixture: browser login failed"); + await page.getByRole("button", { name: "Continue in browser" }).click(); + await expect(page.locator("#login-error")).toContainText("Fixture: browser login failed"); await expect(page.locator("#login-error")).toHaveCSS("color", "rgb(255, 170, 160)"); - await expect(page.getByLabel("Password", { exact: true })).toHaveValue(""); await page.screenshot({ path: info.outputPath("dark-login.png"), fullPage: true }); await page.getByRole("button", { name: "Close accounts" }).click(); await expect(dialog).not.toBeVisible(); diff --git a/test/studio-web.spec.ts b/test/studio-web.spec.ts index aa14b67..983e32a 100644 --- a/test/studio-web.spec.ts +++ b/test/studio-web.spec.ts @@ -7,7 +7,7 @@ import { startStudioWebServer, type StudioWebServer } from "../src/studio-web-se const currentSessions = [ { baseUrl: "https://courses.uit.edu.vn", userId: 101, authMode: "sso", label: "Current Moodle", health: { state: "connected", checkedAt: Date.now() } }, - { baseUrl: "https://coursesold.uit.edu.vn", userId: 202, authMode: "token", label: "Legacy Moodle", health: { state: "connected", checkedAt: Date.now() } } + { baseUrl: "https://coursesold.uit.edu.vn", userId: 202, authMode: "session", label: "Legacy Moodle", health: { state: "connected", checkedAt: Date.now() } } ]; type WebTrace = { leases: any[]; agents: any[] };