From b10eb7217e9fcfa119bb77d1b49fa4a2445717f1 Mon Sep 17 00:00:00 2001 From: Littleor Date: Sun, 21 Jun 2026 06:22:16 +0800 Subject: [PATCH] feat: add password login refresh --- README.md | 39 +++++++++--- src/cli.ts | 122 ++++++++++++++++++++++++++++-------- src/client.ts | 167 ++++++++++++++++++++++++++++++++++++++++++++------ src/config.ts | 28 ++++++++- src/index.ts | 5 ++ src/mcp.ts | 25 +++++++- 6 files changed, 331 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index a244bed..2395b0b 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ makepkg -si ### 1. Authenticate with Overleaf -Get your session cookie from Overleaf.com: +Use a session cookie from Overleaf.com: 1. Log into [overleaf.com](https://www.overleaf.com) 2. Open Developer Tools (F12 or Cmd+Option+I) → Application/Storage → Cookies @@ -106,7 +106,13 @@ Store it with olcli: olcli auth --cookie "your_session_cookie_value" ``` -**Tip:** The cookie stays valid for weeks. Just refresh it when authentication fails. +Or use email/password login on instances that support the standard Overleaf login form: + +```bash +olcli auth --email "you@example.com" --password "your_password" +``` + +Password login stores the account credentials in the local `olcli` config and refreshes the session cookie automatically when it expires. ### 2. List Your Projects @@ -158,7 +164,7 @@ All commands auto-detect the project when run from a synced directory (contains | Command | Description | |---------|-------------| -| `olcli auth` | Set session cookie | +| `olcli auth` | Set session cookie or password login credentials | | `olcli whoami` | Check authentication status | | `olcli logout` | Clear stored credentials | | `olcli list` | List all projects | @@ -363,6 +369,7 @@ Credentials are stored in (checked in order): 1. `OVERLEAF_SESSION` environment variable 2. `.olauth` file in current directory 3. Global config: `~/.config/olcli-nodejs/config.json` (macOS/Linux) +4. Password login credentials in global config (`loginEmail` / `loginPassword`) for automatic session refresh ### .olauth File @@ -388,6 +395,12 @@ olcli config set-url https://latex.example.org olcli config set-cookie-name overleaf.sid ``` +For self-hosted instances with the standard `/login` form, password login can persist the base URL and refresh future sessions: + +```bash +olcli --base-url https://latex.example.org auth --email "you@example.com" --password "your_password" +``` + ## Examples ### Work on a thesis @@ -456,6 +469,13 @@ import { OverleafClient } from '@aloth/olcli'; // Create a client from an Overleaf session cookie const client = await OverleafClient.fromSessionCookie(cookie); +// Or log in with email/password +const passwordClient = await OverleafClient.fromPasswordLogin( + 'you@example.com', + 'your_password', + 'https://latex.example.org' +); + // List all projects const projects = await client.listProjects(); console.log(projects); @@ -487,11 +507,13 @@ import { // Types / interfaces Project, ProjectInfo, FolderEntry, DocEntry, FileEntry, CommentMessage, ProjectComment, CommentContext, CommentStatus, - ListCommentsOptions, AddCommentOptions, Credentials, + ListCommentsOptions, AddCommentOptions, Credentials, SessionCookiePair, // Configuration utilities getBaseUrl, setBaseUrl, getSessionCookie, setSessionCookie, - getSessionCookieName, setSessionCookieName, getCsrf, setCsrf, + getSessionCookieName, setSessionCookieName, + getPasswordCredentials, setPasswordCredentials, clearPasswordCredentials, + getCsrf, setCsrf, getLastProject, setLastProject, clearConfig, getConfigPath, saveOlAuth, // Ignore utilities @@ -527,11 +549,12 @@ import { ### Authentication -The MCP server reads your session cookie in this order: +The MCP server reads credentials in this order: 1. **`OVERLEAF_SESSION` environment variable** — set in your MCP config (recommended) 2. **`.olauth` file in cwd** — written by `olcli auth` 3. **Stored config** — written by `olcli auth` +4. **Stored password login credentials** — written by `olcli auth --email --password ` ### Claude Desktop @@ -611,6 +634,8 @@ Add to `~/.codeium/windsurf/mcp_config.json`: Or run `olcli auth` and then the MCP server will pick it up automatically. +If you used password login, the MCP server will reuse the stored session cookie first and refresh it with the saved password when needed. + ### Self-hosted Overleaf Set `OVERLEAF_BASE_URL` in your MCP env: @@ -628,7 +653,7 @@ Set `OVERLEAF_BASE_URL` in your MCP env: ### Session expired -If you get authentication errors, your session cookie may have expired. Get a fresh one from the browser and run `olcli auth` again. +If you get authentication errors, your session cookie may have expired. Get a fresh one from the browser and run `olcli auth` again, or use password login so `olcli` can refresh the session automatically. ### Compilation fails diff --git a/src/cli.ts b/src/cli.ts index 82102e0..efac8ff 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -36,7 +36,10 @@ import { getBaseUrl, setBaseUrl, getSessionCookieName, - setSessionCookieName + setSessionCookieName, + getPasswordCredentials, + setPasswordCredentials, + type PasswordCredentials } from './config.js'; const program = new Command(); @@ -53,21 +56,53 @@ program * Helper to get authenticated client */ async function getClient(cookieOpt?: string, baseUrlOpt?: string): Promise { - const cookie = cookieOpt || getSessionCookie(); - if (!cookie) { - console.error(chalk.red('No session cookie found.')); - console.error('Set one with: olcli auth --cookie '); - console.error('Or set OVERLEAF_SESSION environment variable'); - console.error('Or create .olauth file in current directory'); - process.exit(1); - } const baseUrl = baseUrlOpt || (program.opts().baseUrl as string | undefined) || getBaseUrl(); const cookieName = (program.opts().cookieName as string | undefined) || getSessionCookieName(); - const client = await OverleafClient.fromSessionCookie(cookie, baseUrl, cookieName); + const cookie = cookieOpt || getSessionCookie(); + const passwordCredentials = cookieOpt ? undefined : getPasswordCredentials(); + + if (cookie) { + try { + const client = await OverleafClient.fromSessionCookie(cookie, baseUrl, cookieName); + if (program.opts().verbose) client.setVerbose(true); + return client; + } catch (error) { + if (!passwordCredentials) throw error; + } + } + + if (passwordCredentials) { + return loginWithSavedPassword(passwordCredentials, baseUrl, cookieName); + } + + console.error(chalk.red('No session cookie or password credentials found.')); + console.error('Set one with: olcli auth --cookie '); + console.error('Or use: olcli auth --email --password '); + console.error('Or set OVERLEAF_SESSION environment variable'); + console.error('Or create .olauth file in current directory'); + process.exit(1); +} + +async function loginWithSavedPassword( + credentials: PasswordCredentials, + baseUrl: string, + cookieName: string +): Promise { + const client = await OverleafClient.fromPasswordLogin(credentials.email, credentials.password, baseUrl); + persistClientSession(client, cookieName); if (program.opts().verbose) client.setVerbose(true); return client; } +function persistClientSession(client: OverleafClient, preferredCookieName: string): void { + const sessionCookie = client.getSessionCookiePair(preferredCookieName); + if (!sessionCookie) { + throw new Error('Password login succeeded, but no session cookie was returned.'); + } + setSessionCookieName(sessionCookie.name); + setSessionCookie(sessionCookie.value); +} + /** * Resolve project from argument or .olcli.json in current directory */ @@ -116,12 +151,15 @@ async function resolveProject( program .command('auth') - .description('Authenticate with Overleaf using session cookie') + .description('Authenticate with Overleaf using a session cookie or email/password') .option('--cookie ', 'Session cookie (overleaf_session2 value)') + .option('--email ', 'Account email for password login') + .option('--password ', 'Account password for password login') + .option('--no-save-password', 'Do not persist email/password credentials') .option('--save-local', 'Save to .olauth in current directory') .action(async (options) => { - if (!options.cookie) { - console.log(chalk.yellow('To authenticate, provide your session cookie:')); + if (!options.cookie && !options.email && !options.password) { + console.log(chalk.yellow('To authenticate, provide a session cookie:')); console.log(); console.log('1. Log into overleaf.com in your browser'); console.log('2. Open Developer Tools (F12) → Application → Cookies'); @@ -130,24 +168,51 @@ program console.log(); console.log(chalk.cyan(' olcli auth --cookie "your_session_cookie_value"')); console.log(); + console.log('Or log in with email/password:'); + console.log(chalk.cyan(' olcli auth --email "you@example.com" --password "your_password"')); + console.log(); console.log('Or set OVERLEAF_SESSION environment variable'); return; } + if (options.cookie && (options.email || options.password)) { + console.error(chalk.red('Use either --cookie or --email/--password, not both.')); + process.exit(1); + } + + if (!options.cookie && (!options.email || !options.password)) { + console.error(chalk.red('Both --email and --password are required for password login.')); + process.exit(1); + } + const spinner = ora('Verifying session...').start(); try { const baseUrl = (program.opts().baseUrl as string | undefined) || getBaseUrl(); const cookieName = (program.opts().cookieName as string | undefined) || getSessionCookieName(); - const client = await OverleafClient.fromSessionCookie(options.cookie, baseUrl, cookieName); - const projects = await client.listProjects(); - setSessionCookie(options.cookie); + if (options.cookie) { + const client = await OverleafClient.fromSessionCookie(options.cookie, baseUrl, cookieName); + const projects = await client.listProjects(); + + setSessionCookie(options.cookie); - if (options.saveLocal) { - saveOlAuth(options.cookie); - spinner.succeed(`Authenticated! Found ${projects.length} projects. Saved to .olauth`); + if (options.saveLocal) { + saveOlAuth(options.cookie); + spinner.succeed(`Authenticated! Found ${projects.length} projects. Saved to .olauth`); + } else { + spinner.succeed(`Authenticated! Found ${projects.length} projects.`); + } } else { - spinner.succeed(`Authenticated! Found ${projects.length} projects.`); + spinner.text = 'Logging in with email/password...'; + const client = await OverleafClient.fromPasswordLogin(options.email, options.password, baseUrl); + const projects = await client.listProjects(); + persistClientSession(client, cookieName); + setBaseUrl(baseUrl); + if (options.savePassword !== false) { + setPasswordCredentials(options.email, options.password); + } + + spinner.succeed(`Authenticated! Found ${projects.length} projects. Password login saved.`); } console.log(chalk.dim(`Config saved to: ${getConfigPath()}`)); @@ -162,16 +227,15 @@ program .description('Show current authentication status') .action(async () => { const cookie = getSessionCookie(); - if (!cookie) { + const passwordCredentials = getPasswordCredentials(); + if (!cookie && !passwordCredentials) { console.log(chalk.yellow('Not authenticated')); return; } const spinner = ora('Checking session...').start(); try { - const baseUrl = (program.opts().baseUrl as string | undefined) || getBaseUrl(); - const cookieName = (program.opts().cookieName as string | undefined) || getSessionCookieName(); - const client = await OverleafClient.fromSessionCookie(cookie, baseUrl, cookieName); + const client = await getClient(); const projects = await client.listProjects(); spinner.succeed(`Authenticated with access to ${projects.length} projects`); } catch (error: any) { @@ -1467,6 +1531,7 @@ program console.log(' 1. OVERLEAF_SESSION environment variable'); console.log(' 2. .olauth file in current directory'); console.log(' 3. Global config file'); + console.log(' 4. Password login credentials in global config'); console.log(); const cookie = getSessionCookie(); @@ -1476,6 +1541,15 @@ program } else { console.log(chalk.yellow('✗ No session cookie found')); } + + const passwordCredentials = getPasswordCredentials(); + if (passwordCredentials) { + console.log(chalk.green('✓ Password login credentials found')); + console.log(chalk.dim(` Email: ${passwordCredentials.email}`)); + console.log(chalk.dim(' Password: [stored]')); + } else { + console.log(chalk.yellow('✗ No password login credentials found')); + } }); program.parse(process.argv); diff --git a/src/client.ts b/src/client.ts index a3e017f..cffacb2 100644 --- a/src/client.ts +++ b/src/client.ts @@ -108,6 +108,11 @@ export interface Credentials { baseUrl?: string; } +export interface SessionCookiePair { + name: string; + value: string; +} + interface ProjectDoc { id: string; path: string; @@ -148,6 +153,34 @@ export class OverleafClient { this.verbose = v; } + getCookie(name: string): string | undefined { + return this.cookies[name]; + } + + getSessionCookiePair(preferredCookieName: string = 'overleaf_session2'): SessionCookiePair | undefined { + const preferredNames = [ + preferredCookieName, + 'overleaf_session2', + 'overleaf.sid', + 'sharelatex.sid' + ]; + const seen = new Set(); + for (const name of preferredNames) { + if (seen.has(name)) continue; + seen.add(name); + const value = this.cookies[name]; + if (value) return { name, value }; + } + + const fallback = Object.entries(this.cookies).find(([name]) => ( + name.toLowerCase().includes('session') || name.toLowerCase().endsWith('.sid') + )); + if (fallback) return { name: fallback[0], value: fallback[1] }; + + const first = Object.entries(this.cookies)[0]; + return first ? { name: first[0], value: first[1] } : undefined; + } + /** * Resolve (and cache) the folder tree for a project. Falls back to a * minimal tree containing only the root folder when the Socket.IO probe @@ -226,37 +259,102 @@ export class OverleafClient { const html = response.body as string; const $ = cheerio.load(html); - // Try multiple methods to find CSRF token (based on PR #66, #82) - let csrf: string | undefined; + if (OverleafClient.isLoginPage($)) { + throw new Error('Authentication required. Session may have expired.'); + } - // Method 1: ol-csrfToken meta tag - csrf = $('meta[name="ol-csrfToken"]').attr('content'); + const csrf = OverleafClient.extractCsrfToken($); - // Method 2: hidden input field if (!csrf) { - csrf = $('input[name="_csrf"]').attr('value'); + throw new Error('Could not find CSRF token. Session may have expired.'); } - // Method 3: Look in script tags for csrfToken + // Update cookies if the bootstrap request added anything + const updatedCookies = bootstrapClient.cookies; + return new OverleafClient({ cookies: updatedCookies, csrf, baseUrl }); + } + + /** + * Create client by submitting Overleaf's email/password login form. + */ + static async fromPasswordLogin( + email: string, + password: string, + baseUrl: string = DEFAULT_BASE_URL + ): Promise { + const bootstrapClient = new OverleafClient({ cookies: {}, csrf: 'bootstrap', baseUrl }); + + const loginPage = await bootstrapClient.httpRequest(`${baseUrl}/login`, { + headers: { 'User-Agent': USER_AGENT }, + expect: 'text' + }); + if (!loginPage.ok) { + throw new Error(`Failed to fetch login page: ${loginPage.status}`); + } + bootstrapClient.applySetCookieHeaders(loginPage.headers['set-cookie'] as string[] | undefined); + + const loginHtml = loginPage.body as string; + const $login = cheerio.load(loginHtml); + const csrf = OverleafClient.extractCsrfToken($login); if (!csrf) { - const scripts = $('script').toArray(); - for (const script of scripts) { - const content = $(script).html() || ''; - const match = content.match(/csrfToken["']?\s*[:=]\s*["']([^"']+)["']/); - if (match) { - csrf = match[1]; - break; - } + throw new Error('Could not find CSRF token on login page.'); + } + + const form = new URLSearchParams(); + form.set('_csrf', csrf); + form.set('email', email); + form.set('password', password); + const body = form.toString(); + + const loginResponse = await bootstrapClient.httpRequest(`${baseUrl}/login`, { + method: 'POST', + headers: { + 'Cookie': bootstrapClient.getCookieHeader(), + 'User-Agent': USER_AGENT, + 'Content-Type': 'application/x-www-form-urlencoded', + 'Content-Length': String(Buffer.byteLength(body)) + }, + body, + expect: 'text', + maxRedirects: 0 + }); + bootstrapClient.applySetCookieHeaders(loginResponse.headers['set-cookie'] as string[] | undefined); + + if (![302, 303].includes(loginResponse.status)) { + const loginFailure = typeof loginResponse.body === 'string' + ? OverleafClient.isLoginPage(cheerio.load(loginResponse.body)) + : false; + if (loginFailure) { + throw new Error('Password login failed. Email or password may be incorrect.'); } + throw new Error(`Password login failed: ${loginResponse.status}`); } - if (!csrf) { - throw new Error('Could not find CSRF token. Session may have expired.'); + const projectUrl = new URL((loginResponse.headers.location as string | undefined) || '/project', baseUrl).toString(); + const projectPage = await bootstrapClient.httpRequest(projectUrl, { + headers: { + 'Cookie': bootstrapClient.getCookieHeader(), + 'User-Agent': USER_AGENT + }, + expect: 'text' + }); + if (!projectPage.ok) { + throw new Error(`Failed to fetch projects page after login: ${projectPage.status}`); } + bootstrapClient.applySetCookieHeaders(projectPage.headers['set-cookie'] as string[] | undefined); - // Update cookies if the bootstrap request added anything - const updatedCookies = bootstrapClient.cookies; - return new OverleafClient({ cookies: updatedCookies, csrf, baseUrl }); + const projectHtml = projectPage.body as string; + const $project = cheerio.load(projectHtml); + if (OverleafClient.isLoginPage($project)) { + throw new Error('Password login failed. Still on login page after submitting credentials.'); + } + + const projectCsrf = OverleafClient.extractCsrfToken($project); + if (!projectCsrf) { + throw new Error('Could not find CSRF token after password login.'); + } + + return new OverleafClient({ cookies: bootstrapClient.cookies, csrf: projectCsrf, baseUrl }); } private getCookieHeader(): string { @@ -296,6 +394,32 @@ export class OverleafClient { } } + private static extractCsrfToken($: cheerio.CheerioAPI): string | undefined { + let csrf = $('meta[name="ol-csrfToken"]').attr('content'); + + if (!csrf) { + csrf = $('input[name="_csrf"]').attr('value'); + } + + if (!csrf) { + const scripts = $('script').toArray(); + for (const script of scripts) { + const content = $(script).html() || ''; + const match = content.match(/csrfToken["']?\s*[:=]\s*["']([^"']+)["']/); + if (match) { + csrf = match[1]; + break; + } + } + } + + return csrf; + } + + private static isLoginPage($: cheerio.CheerioAPI): boolean { + return $('form[name="loginForm"]').length > 0 || $('input[name="password"]').length > 0; + } + private logVerbose(...args: any[]): void { if (this.verbose) { // eslint-disable-next-line no-console @@ -416,6 +540,9 @@ export class OverleafClient { const html = response.body as string; const $ = cheerio.load(html); + if (OverleafClient.isLoginPage($)) { + throw new Error('Authentication required. Session may have expired.'); + } // Try new Overleaf structure first (PR #82) let projectsData: any[] = []; diff --git a/src/config.ts b/src/config.ts index 07bb053..a81abb8 100644 --- a/src/config.ts +++ b/src/config.ts @@ -13,6 +13,13 @@ interface OlcliConfig { lastProject?: string; baseUrl?: string; sessionCookieName?: string; + loginEmail?: string; + loginPassword?: string; +} + +export interface PasswordCredentials { + email: string; + password: string; } const config = new Conf({ @@ -22,7 +29,9 @@ const config = new Conf({ csrf: { type: 'string' }, lastProject: { type: 'string' }, baseUrl: { type: 'string' }, - sessionCookieName: { type: 'string' } + sessionCookieName: { type: 'string' }, + loginEmail: { type: 'string' }, + loginPassword: { type: 'string' } } }); @@ -42,6 +51,23 @@ export function setSessionCookieName(name: string): void { config.set('sessionCookieName', name); } +export function getPasswordCredentials(): PasswordCredentials | undefined { + const email = process.env.OVERLEAF_EMAIL || config.get('loginEmail'); + const password = process.env.OVERLEAF_PASSWORD || config.get('loginPassword'); + if (!email || !password) return undefined; + return { email, password }; +} + +export function setPasswordCredentials(email: string, password: string): void { + config.set('loginEmail', email); + config.set('loginPassword', password); +} + +export function clearPasswordCredentials(): void { + config.delete('loginEmail'); + config.delete('loginPassword'); +} + export function getSessionCookie(): string | undefined { // Check environment variable first if (process.env.OVERLEAF_SESSION) { diff --git a/src/index.ts b/src/index.ts index 2e5a324..2c75084 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,6 +28,7 @@ export { type ListCommentsOptions, type AddCommentOptions, type Credentials, + type SessionCookiePair, // Type aliases type CommentStatus, } from './client.js'; @@ -40,6 +41,10 @@ export { setSessionCookieName, getSessionCookie, setSessionCookie, + getPasswordCredentials, + setPasswordCredentials, + clearPasswordCredentials, + type PasswordCredentials, getCsrf, setCsrf, getLastProject, diff --git a/src/mcp.ts b/src/mcp.ts index 1724e4b..99e09bf 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -27,6 +27,10 @@ import { OverleafClient } from './client.js'; import { getSessionCookie, getBaseUrl, + getSessionCookieName, + setSessionCookie, + setSessionCookieName, + getPasswordCredentials, } from './config.js'; // --------------------------------------------------------------------------- @@ -59,7 +63,7 @@ function resolveSessionCookie(): string { throw new Error( 'No Overleaf session cookie found.\n' + - 'Set OVERLEAF_SESSION= or run `olcli auth` first.' + 'Set OVERLEAF_SESSION=, run `olcli auth`, or save password login credentials with `olcli auth --email --password `.' ); } @@ -71,9 +75,24 @@ let _client: OverleafClient | null = null; async function getClient(): Promise { if (_client) return _client; - const cookie = resolveSessionCookie(); const baseUrl = process.env.OVERLEAF_BASE_URL ?? getBaseUrl(); - _client = await OverleafClient.fromSessionCookie(cookie, baseUrl); + const cookieName = getSessionCookieName(); + + try { + const cookie = resolveSessionCookie(); + _client = await OverleafClient.fromSessionCookie(cookie, baseUrl, cookieName); + } catch (error) { + const credentials = getPasswordCredentials(); + if (!credentials) throw error; + + _client = await OverleafClient.fromPasswordLogin(credentials.email, credentials.password, baseUrl); + const sessionCookie = _client.getSessionCookiePair(cookieName); + if (sessionCookie) { + setSessionCookieName(sessionCookie.name); + setSessionCookie(sessionCookie.value); + } + } + return _client; }