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);
}

/**
* 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}`);

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

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

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

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 on lines 122 to 124
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Accepting redirects as success may cause false positives.

The validateStatus accepts 3xx redirects as successful responses, but a redirect might indicate failed authentication rather than success. While the code does validate Set-Cookie headers and session cookies afterward, relying on redirect status codes is fragile.

This is related to the authentication verification issue already flagged at lines 108-110—making a test API request after login would definitively verify the session works regardless of the HTTP status code.

🤖 Prompt for AI Agents
In src/common/auth.ts around lines 73 to 75, the validateStatus currently treats
3xx redirects as success which can produce false positives for authentication;
change validateStatus to accept only 2xx (status >= 200 && status < 300) while
still keeping maxRedirects: 0 so cookies are captured, and after the login
request make an explicit follow-up test API request (using the same cookie
jar/session) to a protected endpoint and assert a 2xx response and expected
session behavior — fail the login flow if that verification request does not
return a successful authenticated response.

);

// Log response details
debugLog(`[AUTH] Login response status: ${loginResponse.status}`);
debugLog(`[AUTH] Login response headers: ${JSON.stringify(loginResponse.headers)}`);

// 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 {
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) };
}
}

/**
* 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");
}
Loading