Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@
"@modelcontextprotocol/sdk": "^1.9.0",
"@scarf/scarf": "^1.4.0",
"axios": "1.12.0",
"axios-cookiejar-support": "^6.0.4",
"tough-cookie": "^5.1.2",
"zod-to-json-schema": "^3.24.5"
},
"devDependencies": {
Expand Down
192 changes: 192 additions & 0 deletions src/common/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import axios, { AxiosInstance } from "axios";
import { wrapper } from "axios-cookiejar-support";
import { CookieJar } from "tough-cookie";
import fs from "fs";
import path from "path";

const logFile = path.join("/tmp", "plane-mcp-debug.log");
function debugLog(message: string) {
const timestamp = new Date().toISOString();
const logMessage = `[${timestamp}] ${message}\n`;
fs.appendFileSync(logFile, logMessage);
console.error(message);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

/**
* Result of an authentication attempt
* @property success - Whether authentication was successful
* @property error - Type of error if authentication failed
* @property message - Detailed error message if authentication failed
*/
export interface AuthResult {
success: boolean;
error?: 'network' | 'csrf' | 'credentials' | 'cookies' | 'unknown';
message?: string;
}

let axiosInstance: AxiosInstance | null = null;
let isAuthenticated = false;

debugLog(`[AUTH] Module loaded - PID: ${process.pid}`);

/**
* Gets or creates an Axios instance with cookie jar support for session authentication
* @returns Configured Axios instance with cookie persistence
*/
export function getAxiosInstance(): AxiosInstance {
if (!axiosInstance) {
debugLog("[AUTH] Creating new axios instance with cookie jar");
const jar = new CookieJar();
axiosInstance = wrapper(axios.create({ jar, withCredentials: true }));
} else {
debugLog("[AUTH] Reusing existing axios instance");
}
return axiosInstance;
}

/**
* Authenticates with Plane using email and password, establishing a session with cookies
*
* This function performs a two-step authentication flow:
* 1. Requests a CSRF token from the server
* 2. Submits credentials with CSRF token to establish session
*
* Session cookies are automatically stored in the axios instance's cookie jar
* and will be included in subsequent requests to /api/ endpoints.
*
* @param email - User's Plane account email address
* @param password - User's Plane account password
* @param hostUrl - Plane server URL (e.g., "https://api.plane.so/" or self-hosted URL)
* @returns Authentication result with success status and error details if failed
* @throws Never throws - all errors are captured in AuthResult
*/
export async function authenticateWithPassword(
email: string,
password: string,
hostUrl: string
): Promise<AuthResult> {
try {
const instance = getAxiosInstance();
const host = hostUrl.endsWith("/") ? hostUrl : `${hostUrl}/`;

debugLog("[AUTH] Starting authentication flow...");
debugLog(`[AUTH] Host URL: ${host}`);

Comment on lines +60 to +71
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Avoid stale isAuthenticated=true after a failed re-login
If a user was previously authenticated and a later authenticateWithPassword() fails, isAuthenticated is never reset in the failure paths. Set it false at the start (and ideally clear cookies) before attempting login.

 export async function authenticateWithPassword(
   email: string,
   password: string,
   hostUrl: string
 ): Promise<AuthResult> {
+  // Prevent stale auth state from prior successful logins
+  isAuthenticated = false;
   try {
     const instance = getAxiosInstance();

Also applies to: 176-193

🤖 Prompt for AI Agents
In src/common/auth.ts around lines 54 to 65, the function
authenticateWithPassword can leave isAuthenticated=true after a failed re-login;
set isAuthenticated = false at the start of the method (before any network/login
attempt) and clear any stored auth cookies/tokens/session state so we don't
retain stale auth on failure; also update the failure/catch branches to ensure
isAuthenticated stays false and any partial credentials are removed; apply the
same changes to the other similar login routine at lines 176-193.

// Step 1: Get CSRF token (stored in cookie jar automatically)
await instance.get(`${host}auth/get-csrf-token/`);
debugLog("[AUTH] CSRF token requested");

Comment thread
coderabbitai[bot] marked this conversation as resolved.
// Step 2: Extract CSRF token from cookie jar for the request header
const jar = (instance.defaults as any).jar as CookieJar;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
const cookies = await jar.getCookies(host);
debugLog(`[AUTH] Cookies after CSRF request: ${cookies.map(c => `${c.key}=${c.value.substring(0, 10)}...`).join(", ")}`);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

const csrfCookie = cookies.find((c) => c.key === "csrftoken");

if (!csrfCookie) {
debugLog("[AUTH] CSRF token not found in cookies");
return { success: false, error: 'csrf', message: 'CSRF token not found in response' };
}

// Step 3: Login with email, password, and CSRF token
// Send as form data (application/x-www-form-urlencoded) not JSON
const formData = new URLSearchParams();
formData.append('email', email);
formData.append('password', password);

const loginResponse = await instance.post(
`${host}auth/sign-in/`,
formData.toString(),
{
headers: {
"X-CSRFToken": csrfCookie.value,
"Content-Type": "application/x-www-form-urlencoded",
},
maxRedirects: 0, // Don't follow redirects, we just need the cookies
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Disabled redirects may prevent session cookie capture

Setting maxRedirects: 0 prevents axios from following redirects after login. If the Plane server sets session cookies on the redirect target response (common in web authentication flows) rather than on the initial 302 response, those cookies won't be captured. This aligns with the user's reported error about missing session cookies. Many authentication systems only establish session cookies after the redirect is followed to the final destination.

Fix in Cursor Fix in Web

validateStatus: (status) => status >= 200 && status < 400, // Accept redirects as success
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
);

// Log response details
debugLog(`[AUTH] Login response status: ${loginResponse.status}`);
debugLog(`[AUTH] Login response headers: ${JSON.stringify(loginResponse.headers)}`);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

// Check if Set-Cookie headers are present
const setCookieHeader = loginResponse.headers['set-cookie'];
if (setCookieHeader) {
debugLog(`[AUTH] Set-Cookie headers received: ${JSON.stringify(setCookieHeader)}`);
} else {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
debugLog(`[AUTH] ERROR: No Set-Cookie headers in login response!`);
return { success: false, error: 'cookies', message: 'No session cookies received from server' };
}

// Verify cookies were stored in the jar
const loginCookies = await jar.getCookies(host);
debugLog(`[AUTH] Cookies after login: ${loginCookies.map(c => `${c.key}=${c.value.substring(0, 10)}...`).join(", ")}`);
debugLog(`[AUTH] Total cookies stored: ${loginCookies.length}`);

// Validate that session cookie was received
const sessionCookie = loginCookies.find((c) => c.key === "session-id");
if (!sessionCookie) {
debugLog("[AUTH] ERROR: session-id cookie not found after login!");
return { success: false, error: 'cookies', message: 'session-id cookie not found after login' };
}

// Log full cookie details for debugging
loginCookies.forEach(c => {
debugLog(`[AUTH] Cookie detail - ${c.key}: domain=${c.domain}, path=${c.path}, httpOnly=${c.httpOnly}, secure=${c.secure}`);
});

isAuthenticated = true;
debugLog("[AUTH] Authentication successful");
return { success: true };
} catch (error) {
debugLog(`[AUTH] Authentication failed: ${error}`);

if (axios.isAxiosError(error)) {
if (!error.response) {
return { success: false, error: 'network', message: 'Network error - could not connect to server' };
}
if (error.response.status === 401 || error.response.status === 403) {
return { success: false, error: 'credentials', message: 'Invalid email or password' };
}
return { success: false, error: 'unknown', message: `Server error: ${error.response.status}` };
}

return { success: false, error: 'unknown', message: String(error) };
}
Comment thread
cursor[bot] marked this conversation as resolved.
}

/**
* Checks whether a session is currently authenticated
* @returns true if authenticated, false otherwise
*/
export function isSessionAuthenticated(): boolean {
debugLog(`[AUTH] isSessionAuthenticated() called - returning: ${isAuthenticated}`);
return isAuthenticated;
}

/**
* Resets the authentication state and clears all session cookies
*
* This function:
* 1. Removes all cookies from the cookie jar
* 2. Clears the axios instance
* 3. Resets authentication flag
*
* Call this when logging out or when authentication needs to be cleared.
*
* @returns Promise that resolves when authentication is reset
*/
export async function resetAuthentication(): Promise<void> {
if (axiosInstance) {
const jar = (axiosInstance.defaults as any).jar as CookieJar | undefined;
if (jar) {
await jar.removeAllCookies();
debugLog("[AUTH] Cookie jar cleared");
}
}
axiosInstance = null;
isAuthenticated = false;
debugLog("[AUTH] Authentication reset");
}
Comment thread
cursor[bot] marked this conversation as resolved.
Loading