Skip to content

Commit 8a396c6

Browse files
committed
fix(auth): address PR feedback on security and reliability
- Implemented session expiry tracking (1 hour timeout). - Secured cookie retrieval to include path-scoped CSRF tokens. - Added 30s timeout to axios instance. - Explicitly accept 302 redirects for login endpoint while rejecting other 3xx/4xx/5xx in auth flow. - Clarified auth tool descriptions regarding session vs API key scope. - Ensured authentication state is reset on failure.
1 parent 01cfbe2 commit 8a396c6

3 files changed

Lines changed: 55 additions & 21 deletions

File tree

src/common/auth.ts

Lines changed: 49 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ export interface AuthResult {
1717

1818
let axiosInstance: AxiosInstance | null = null;
1919
let isAuthenticated = false;
20+
let authenticationTime: number | null = null;
21+
const SESSION_TIMEOUT_MS = 3600000; // 1 hour
2022

2123
debugLog(`[AUTH] Module loaded - PID: ${process.pid}`).catch(() => {});
2224

@@ -28,7 +30,11 @@ export function getAxiosInstance(): AxiosInstance {
2830
if (!axiosInstance) {
2931
debugLog("[AUTH] Creating new axios instance with cookie jar").catch(() => {});
3032
const jar = new CookieJar();
31-
axiosInstance = wrapper(axios.create({ jar, withCredentials: true }));
33+
axiosInstance = wrapper(axios.create({
34+
jar,
35+
withCredentials: true,
36+
timeout: 30000 // 30 second timeout
37+
}));
3238
} else {
3339
debugLog("[AUTH] Reusing existing axios instance").catch(() => {});
3440
}
@@ -64,9 +70,16 @@ export async function authenticateWithPassword(
6470
await debugLog(`[AUTH] Host URL: ${host}`);
6571

6672
// Step 1: Get CSRF token (stored in cookie jar automatically)
67-
const csrfResponse = await instance.get(`${host}auth/get-csrf-token/`);
73+
// Use explicit path to ensure we capture path-scoped cookies
74+
const csrfUrl = `${host}auth/get-csrf-token/`;
75+
const csrfResponse = await instance.get(csrfUrl);
6876
await debugLog(`[AUTH] CSRF response status: ${csrfResponse.status}`);
69-
await debugLog(`[AUTH] CSRF response headers: ${JSON.stringify(csrfResponse.headers)}`);
77+
78+
// Only log headers if verbose debug is enabled to avoid leaking config details
79+
if (process.env.PLANE_MCP_DEBUG === 'verbose') {
80+
await debugLog(`[AUTH] CSRF response headers: ${JSON.stringify(csrfResponse.headers)}`);
81+
}
82+
7083
await debugLog("[AUTH] CSRF token requested");
7184

7285
// Step 2: Extract CSRF token from cookie jar for the request header
@@ -76,7 +89,8 @@ export async function authenticateWithPassword(
7689
return { success: false, error: "cookies", message: "Cookie jar not available for session authentication" };
7790
}
7891
const jar = maybeJar;
79-
const cookies = await jar.getCookies(host);
92+
// Check cookies on the specific CSRF URL to ensure we get path-scoped cookies
93+
const cookies = await jar.getCookies(csrfUrl);
8094
await debugLog(`[AUTH] Cookies after CSRF request: ${cookies.map(c => c.key).join(", ")}`);
8195

8296
const csrfCookie = cookies.find((c) => ["csrftoken", "csrf", "XSRF-TOKEN"].includes(c.key));
@@ -106,7 +120,7 @@ export async function authenticateWithPassword(
106120
"Content-Type": "application/x-www-form-urlencoded",
107121
},
108122
maxRedirects: 0, // Don't follow redirects, we just need the cookies
109-
validateStatus: (status) => status >= 200 && status < 400, // Accept redirects as success
123+
validateStatus: (status) => (status >= 200 && status < 300) || status === 302, // Accept 2xx and 302 (redirect) as success
110124
}
111125
);
112126

@@ -115,16 +129,17 @@ export async function authenticateWithPassword(
115129
const headerNames = Object.keys(loginResponse.headers ?? {});
116130
await debugLog(`[AUTH] Login response headers present: ${headerNames.join(", ")}`);
117131

118-
// Log ALL headers for debugging
119-
await debugLog(`[AUTH] Login response headers FULL: ${JSON.stringify(loginResponse.headers)}`);
132+
// Log ALL headers for debugging ONLY if verbose
133+
if (process.env.PLANE_MCP_DEBUG === 'verbose') {
134+
await debugLog(`[AUTH] Login response headers FULL: ${JSON.stringify(loginResponse.headers)}`);
135+
}
120136

121137
// Check if Set-Cookie headers are present
122138
const setCookieHeader = loginResponse.headers['set-cookie'];
123139
if (setCookieHeader) {
124140
await debugLog(`[AUTH] Set-Cookie headers received: ${Array.isArray(setCookieHeader) ? setCookieHeader.length : 1} cookie(s)`);
125141
} else {
126142
await debugLog(`[AUTH] WARNING: No Set-Cookie headers in login response! Checking cookie jar anyway...`);
127-
// We don't return error here, we assume cookies might be in the jar (e.g. from redirects or axios processing)
128143
}
129144

130145
// Verify cookies were stored in the jar
@@ -137,17 +152,16 @@ export async function authenticateWithPassword(
137152
const sessionCookie = loginCookies.find((c) => sessionCookieNames.includes(c.key));
138153
if (!sessionCookie) {
139154
await debugLog(`[AUTH] WARNING: No standard session cookie found (looked for: ${sessionCookieNames.join(", ")})`);
140-
// We don't return error here anymore, we let the verification step decide
141155
}
142156

143-
// Log full cookie details for debugging
144-
loginCookies.forEach(c => {
145-
// Redacted logging of cookie values
146-
debugLog(`[AUTH] Cookie detail - ${c.key}: domain=${c.domain}, path=${c.path}, httpOnly=${c.httpOnly}, secure=${c.secure}`).catch(() => {});
147-
});
157+
// Log full cookie details for debugging - gated
158+
if (process.env.PLANE_MCP_DEBUG === 'verbose') {
159+
loginCookies.forEach(c => {
160+
debugLog(`[AUTH] Cookie detail - ${c.key}: domain=${c.domain}, path=${c.path}, httpOnly=${c.httpOnly}, secure=${c.secure}`).catch(() => {});
161+
});
162+
}
148163

149164
// Verify the session works with a test API call
150-
// Note: Use /api/ endpoint (not /api/v1/) since session cookies work with /api/ endpoints
151165
try {
152166
const verifyUrl = `${host}api/users/me/`;
153167
await debugLog(`[AUTH] Attempting to verify session with: ${verifyUrl}`);
@@ -174,9 +188,14 @@ export async function authenticateWithPassword(
174188
}
175189

176190
isAuthenticated = true;
191+
authenticationTime = Date.now();
177192
await debugLog("[AUTH] Authentication successful");
178193
return { success: true };
179194
} catch (error) {
195+
// Reset auth state on failure to avoid stale state
196+
isAuthenticated = false;
197+
authenticationTime = null;
198+
180199
await debugLog(`[AUTH] Authentication failed: ${error}`);
181200

182201
if (axios.isAxiosError(error)) {
@@ -198,6 +217,19 @@ export async function authenticateWithPassword(
198217
* @returns true if authenticated, false otherwise
199218
*/
200219
export function isSessionAuthenticated(): boolean {
220+
if (!isAuthenticated || !authenticationTime) {
221+
debugLog(`[AUTH] isSessionAuthenticated() - not authenticated`).catch(() => {});
222+
return false;
223+
}
224+
225+
const isStale = Date.now() - authenticationTime > SESSION_TIMEOUT_MS;
226+
if (isStale) {
227+
debugLog(`[AUTH] Session expired, resetting authentication`).catch(() => {});
228+
isAuthenticated = false;
229+
authenticationTime = null;
230+
return false;
231+
}
232+
201233
debugLog(`[AUTH] isSessionAuthenticated() called - returning: ${isAuthenticated}`).catch(() => {});
202234
return isAuthenticated;
203235
}
@@ -230,6 +262,7 @@ export async function resetAuthentication(): Promise<void> {
230262
} finally {
231263
axiosInstance = null;
232264
isAuthenticated = false;
265+
authenticationTime = null;
233266
await debugLog("[AUTH] Authentication reset");
234267
}
235-
}
268+
}

src/common/request-helper.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,8 @@ export async function makePlaneRequest<T>(method: string, path: string, body: an
6666

6767
const jar = (sessionAxios.defaults as any).jar;
6868
if (jar) {
69-
const cookies = await jar.getCookies(host);
69+
// Use full URL to match path-scoped cookies
70+
const cookies = await jar.getCookies(url);
7071
const csrfCookie = cookies.find((c: any) => ["csrftoken", "csrf", "XSRF-TOKEN"].includes(c.key));
7172
if (csrfCookie) {
7273
headers["X-CSRFToken"] = csrfCookie.value;

src/tools/auth.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ export const registerAuthTools = (server: McpServer) => {
3333
{
3434
message: "Successfully authenticated with Plane",
3535
authenticated: true,
36-
note: "Session authentication enabled for Pages and /api/ endpoints. Other endpoints (/api/v1/) use API key if configured.",
36+
note: "Session authentication enables access to Pages and /api/ endpoints. Standard /api/v1/ endpoints (Issues, Projects, etc.) still require an API key if configured.",
3737
},
3838
null,
3939
2
@@ -81,10 +81,10 @@ export const registerAuthTools = (server: McpServer) => {
8181
api_key_configured: hasApiKey,
8282
current_mode: authenticated ? "session (Pages + /api/ endpoints)" : hasApiKey ? "api_key (/api/v1/ endpoints)" : "unauthenticated",
8383
note: authenticated
84-
? "Using session authentication - access to Pages and /api/ endpoints"
84+
? "Session active: Access to Pages and /api/ endpoints enabled. (API key required for /api/v1/ endpoints)"
8585
: hasApiKey
86-
? "Using API key - access to /api/v1/ endpoints only"
87-
: "No authentication configured",
86+
? "API Key active: Access to /api/v1/ endpoints only."
87+
: "No authentication configured.",
8888
},
8989
null,
9090
2

0 commit comments

Comments
 (0)