From 56270d99f3511058bb71390df4c0b85f4a0e54e0 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 11 Feb 2026 17:55:15 +0700 Subject: [PATCH 1/6] feat(cursor): add auth module with SQLite auto-detect and types - Add CursorCredentials, CursorAuthStatus, and related types - Implement autoDetectTokens() using sqlite3 CLI (no native deps) - Add validateToken() for token format validation - Add extractUserInfo() for JWT decoding - Add saveCredentials/loadCredentials using getCcsDir() - Add checkAuthStatus() for auth verification - Platform-specific state.vscdb paths (Linux/macOS/Windows) - Graceful error handling for missing database/tokens Refs: #519 (sub-task of #517) --- src/cursor/cursor-auth.ts | 246 ++++++++++++++++++++++++++++++++++++++ src/cursor/types.ts | 141 ++++++++++++++++++++++ 2 files changed, 387 insertions(+) create mode 100644 src/cursor/cursor-auth.ts create mode 100644 src/cursor/types.ts diff --git a/src/cursor/cursor-auth.ts b/src/cursor/cursor-auth.ts new file mode 100644 index 00000000..a7a62c53 --- /dev/null +++ b/src/cursor/cursor-auth.ts @@ -0,0 +1,246 @@ +/** + * Cursor IDE Authentication Handler + * + * Handles token import and authentication for Cursor IDE integration. + * Supports auto-detection from Cursor's SQLite database. + * + * Token Location: + * - Linux: ~/.config/Cursor/User/globalStorage/state.vscdb + * - macOS: ~/Library/Application Support/Cursor/User/globalStorage/state.vscdb + * - Windows: %APPDATA%\Cursor\User\globalStorage\state.vscdb + * + * Database Keys: + * - cursorAuth/accessToken: Access token + * - storage.serviceMachineId: Machine ID for checksum + */ + +import { execSync } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import type { CursorCredentials, CursorAuthStatus, AutoDetectResult } from './types'; +import { getCcsDir } from '../utils/config-manager'; + +/** + * Get platform-specific path to Cursor's state.vscdb + */ +export function getTokenStoragePath(): string { + const platform = process.platform; + const home = os.homedir(); + + if (platform === 'win32') { + const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming'); + return path.join(appData, 'Cursor', 'User', 'globalStorage', 'state.vscdb'); + } else if (platform === 'darwin') { + return path.join( + home, + 'Library', + 'Application Support', + 'Cursor', + 'User', + 'globalStorage', + 'state.vscdb' + ); + } else { + // Linux + return path.join(home, '.config', 'Cursor', 'User', 'globalStorage', 'state.vscdb'); + } +} + +/** + * Query Cursor's SQLite database using sqlite3 CLI + */ +function queryStateDb(dbPath: string, key: string): string | null { + try { + const result = execSync( + `sqlite3 "${dbPath}" "SELECT value FROM itemTable WHERE key='${key}'" 2>/dev/null`, + { encoding: 'utf8', timeout: 5000 } + ).trim(); + return result || null; + } catch { + return null; + } +} + +/** + * Auto-detect tokens from Cursor's SQLite database + */ +export function autoDetectTokens(): AutoDetectResult { + const dbPath = getTokenStoragePath(); + + // Check if database exists + if (!fs.existsSync(dbPath)) { + return { + found: false, + error: + 'Cursor state database not found. Make sure Cursor IDE is installed and you are logged in.', + }; + } + + // Try to query access token + const accessToken = queryStateDb(dbPath, 'cursorAuth/accessToken'); + if (!accessToken) { + return { + found: false, + error: 'Access token not found in database. Please log in to Cursor IDE first.', + }; + } + + // Try to query machine ID + const machineId = queryStateDb(dbPath, 'storage.serviceMachineId'); + if (!machineId) { + return { + found: false, + error: 'Machine ID not found in database.', + }; + } + + return { + found: true, + accessToken, + machineId, + }; +} + +/** + * Validate token and machine ID format + */ +export function validateToken(accessToken: string, machineId: string): boolean { + // Basic validation + if (!accessToken || typeof accessToken !== 'string') { + return false; + } + + if (!machineId || typeof machineId !== 'string') { + return false; + } + + // Token format validation (Cursor tokens are typically long strings) + if (accessToken.length < 50) { + return false; + } + + // Machine ID format validation (should be UUID-like) + const uuidRegex = /^[a-f0-9-]{32,}$/i; + if (!uuidRegex.test(machineId.replace(/-/g, ''))) { + return false; + } + + return true; +} + +/** + * Extract user info from token if possible + * Cursor tokens may contain encoded user info as JWT + */ +export function extractUserInfo(accessToken: string): { email?: string; userId?: string } | null { + try { + // Try to decode as JWT + const parts = accessToken.split('.'); + if (parts.length === 3) { + let payload = parts[1]; + // Add padding if needed + while (payload.length % 4) { + payload += '='; + } + const decoded = JSON.parse( + Buffer.from(payload.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString() + ); + return { + email: decoded.email || decoded.sub, + userId: decoded.sub || decoded.user_id, + }; + } + } catch { + // Token is not a JWT, that's okay + } + + return null; +} + +/** + * Get path to credentials file + */ +export function getCredentialsPath(): string { + return path.join(getCcsDir(), 'cursor', 'credentials.json'); +} + +/** + * Save credentials to CCS config directory + */ +export function saveCredentials(credentials: CursorCredentials): void { + const credPath = getCredentialsPath(); + const dir = path.dirname(credPath); + + // Ensure directory exists + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + // Write credentials + fs.writeFileSync(credPath, JSON.stringify(credentials, null, 2), 'utf8'); +} + +/** + * Load credentials from CCS config directory + */ +export function loadCredentials(): CursorCredentials | null { + const credPath = getCredentialsPath(); + + if (!fs.existsSync(credPath)) { + return null; + } + + try { + const raw = fs.readFileSync(credPath, 'utf8'); + const parsed: unknown = JSON.parse(raw); + + // Basic validation + if ( + typeof parsed === 'object' && + parsed !== null && + 'accessToken' in parsed && + 'machineId' in parsed && + 'authMethod' in parsed && + 'importedAt' in parsed + ) { + return parsed as CursorCredentials; + } + + return null; + } catch { + return null; + } +} + +/** + * Check authentication status + */ +export function checkAuthStatus(): CursorAuthStatus { + const credentials = loadCredentials(); + + if (!credentials) { + return { authenticated: false }; + } + + // Validate credentials are still valid format + if (!validateToken(credentials.accessToken, credentials.machineId)) { + return { authenticated: false }; + } + + // Calculate token age in hours + let tokenAge: number | undefined; + try { + const importedDate = new Date(credentials.importedAt); + const now = new Date(); + tokenAge = Math.floor((now.getTime() - importedDate.getTime()) / (1000 * 60 * 60)); + } catch { + // Invalid date format + } + + return { + authenticated: true, + credentials, + tokenAge, + }; +} diff --git a/src/cursor/types.ts b/src/cursor/types.ts new file mode 100644 index 00000000..e22c37ae --- /dev/null +++ b/src/cursor/types.ts @@ -0,0 +1,141 @@ +/** + * Cursor IDE Type Definitions + * + * TypeScript interfaces for the Cursor module. + */ + +/** + * Cursor authentication credentials + */ +export interface CursorCredentials { + /** Access token from Cursor IDE */ + accessToken: string; + /** Machine ID for checksum generation */ + machineId: string; + /** User email (if available from token) */ + email?: string; + /** User ID (if available from token) */ + userId?: string; + /** How credentials were obtained */ + authMethod: 'auto-detect' | 'manual'; + /** ISO datetime when credentials were imported */ + importedAt: string; +} + +/** + * Cursor authentication status + */ +export interface CursorAuthStatus { + /** Whether user is authenticated */ + authenticated: boolean; + /** Current credentials (if authenticated) */ + credentials?: CursorCredentials; + /** Hours since credentials were imported (if available) */ + tokenAge?: number; +} + +/** + * Cursor daemon/process status + */ +export interface CursorDaemonStatus { + /** Whether daemon is running */ + running: boolean; + /** Port number daemon is listening on */ + port: number; + /** Process ID (if available) */ + pid?: number; +} + +/** + * Cursor AI model + */ +export interface CursorModel { + /** Model ID */ + id: string; + /** Display name */ + name: string; + /** Provider (e.g., 'openai', 'anthropic') */ + provider: string; + /** Whether this is the default model */ + isDefault?: boolean; +} + +/** + * Message role + */ +export type MessageRole = 'user' | 'assistant'; + +/** + * Cursor message for protobuf + */ +export interface CursorMessage { + /** Message role */ + role: MessageRole; + /** Message content */ + content: string; + /** Tool calls (if any) */ + tool_calls?: CursorToolCall[]; + /** Tool results (if any) */ + tool_results?: CursorToolResult[]; +} + +/** + * Cursor tool call + */ +export interface CursorToolCall { + /** Unique ID for this tool call */ + id: string; + /** Type of tool call */ + type: 'function'; + /** Function details */ + function: { + /** Function name */ + name: string; + /** JSON-encoded arguments */ + arguments: string; + }; + /** Whether this is the last tool call in sequence */ + isLast?: boolean; +} + +/** + * Cursor tool result + */ +export interface CursorToolResult { + /** ID of the tool call this result is for */ + tool_call_id: string; + /** Tool name */ + name: string; + /** Result index */ + index: number; + /** Raw arguments */ + raw_args: string; +} + +/** + * Result from protobuf extraction + */ +export interface ProtobufExtractResult { + /** Extracted text content */ + text: string | null; + /** Error message (if extraction failed) */ + error: string | null; + /** Extracted tool call (if any) */ + toolCall: CursorToolCall | null; + /** Thinking/reasoning content (if any) */ + thinking: string | null; +} + +/** + * Auto-detection result + */ +export interface AutoDetectResult { + /** Whether tokens were found */ + found: boolean; + /** Access token (if found) */ + accessToken?: string; + /** Machine ID (if found) */ + machineId?: string; + /** Error message (if detection failed) */ + error?: string; +} From aeb580281fc4b9f0d585a99e95730e65bd6fae6f Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 11 Feb 2026 19:06:57 +0700 Subject: [PATCH 2/6] fix(cursor): harden auth security and add token expiry warning --- src/cursor/cursor-auth.ts | 34 ++++++++++++++++++++++++++-------- src/cursor/types.ts | 2 ++ 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/src/cursor/cursor-auth.ts b/src/cursor/cursor-auth.ts index a7a62c53..5a0fa58b 100644 --- a/src/cursor/cursor-auth.ts +++ b/src/cursor/cursor-auth.ts @@ -14,7 +14,7 @@ * - storage.serviceMachineId: Machine ID for checksum */ -import { execSync } from 'child_process'; +import { execFileSync } from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; @@ -52,9 +52,10 @@ export function getTokenStoragePath(): string { */ function queryStateDb(dbPath: string, key: string): string | null { try { - const result = execSync( - `sqlite3 "${dbPath}" "SELECT value FROM itemTable WHERE key='${key}'" 2>/dev/null`, - { encoding: 'utf8', timeout: 5000 } + const result = execFileSync( + 'sqlite3', + [dbPath, `SELECT value FROM itemTable WHERE key='${key}'`], + { encoding: 'utf8', timeout: 5000, stdio: ['pipe', 'pipe', 'ignore'] } ).trim(); return result || null; } catch { @@ -66,6 +67,15 @@ function queryStateDb(dbPath: string, key: string): string | null { * Auto-detect tokens from Cursor's SQLite database */ export function autoDetectTokens(): AutoDetectResult { + // sqlite3 CLI is not bundled with Windows + if (process.platform === 'win32') { + return { + found: false, + error: + 'Auto-detection is not supported on Windows. Please import tokens manually using ccs cursor auth --manual.', + }; + } + const dbPath = getTokenStoragePath(); // Check if database exists @@ -172,13 +182,16 @@ export function saveCredentials(credentials: CursorCredentials): void { const credPath = getCredentialsPath(); const dir = path.dirname(credPath); - // Ensure directory exists + // Ensure directory exists with restrictive permissions if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); } - // Write credentials - fs.writeFileSync(credPath, JSON.stringify(credentials, null, 2), 'utf8'); + // Write credentials with restrictive permissions + fs.writeFileSync(credPath, JSON.stringify(credentials, null, 2), { + encoding: 'utf8', + mode: 0o600, + }); } /** @@ -230,10 +243,14 @@ export function checkAuthStatus(): CursorAuthStatus { // Calculate token age in hours let tokenAge: number | undefined; + let expired = false; + const TOKEN_EXPIRY_HOURS = 24; + try { const importedDate = new Date(credentials.importedAt); const now = new Date(); tokenAge = Math.floor((now.getTime() - importedDate.getTime()) / (1000 * 60 * 60)); + expired = tokenAge >= TOKEN_EXPIRY_HOURS; } catch { // Invalid date format } @@ -242,5 +259,6 @@ export function checkAuthStatus(): CursorAuthStatus { authenticated: true, credentials, tokenAge, + expired, }; } diff --git a/src/cursor/types.ts b/src/cursor/types.ts index e22c37ae..10c33ad2 100644 --- a/src/cursor/types.ts +++ b/src/cursor/types.ts @@ -32,6 +32,8 @@ export interface CursorAuthStatus { credentials?: CursorCredentials; /** Hours since credentials were imported (if available) */ tokenAge?: number; + /** Whether token has expired (>24 hours old) */ + expired?: boolean; } /** From 7e4f08004c00771ee624a09c045f920a5e105358 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 11 Feb 2026 19:31:04 +0700 Subject: [PATCH 3/6] fix(cursor): address code review feedback on auth module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sanitize SQL key parameter in queryStateDb to prevent injection - Fix UUID regex: use exact {32} length, remove redundant hyphen from stripped character class - Remove unused types from types.ts (YAGNI): CursorDaemonStatus, CursorModel, MessageRole, CursorMessage, CursorToolCall, CursorToolResult, ProtobufExtractResult — belong in their respective module PRs --- src/cursor/cursor-auth.ts | 10 +++-- src/cursor/types.ts | 94 +-------------------------------------- 2 files changed, 7 insertions(+), 97 deletions(-) diff --git a/src/cursor/cursor-auth.ts b/src/cursor/cursor-auth.ts index 5a0fa58b..7cf7b355 100644 --- a/src/cursor/cursor-auth.ts +++ b/src/cursor/cursor-auth.ts @@ -52,9 +52,11 @@ export function getTokenStoragePath(): string { */ function queryStateDb(dbPath: string, key: string): string | null { try { + // Escape single quotes to prevent SQL injection + const sanitizedKey = key.replace(/'/g, "''"); const result = execFileSync( 'sqlite3', - [dbPath, `SELECT value FROM itemTable WHERE key='${key}'`], + [dbPath, `SELECT value FROM itemTable WHERE key='${sanitizedKey}'`], { encoding: 'utf8', timeout: 5000, stdio: ['pipe', 'pipe', 'ignore'] } ).trim(); return result || null; @@ -130,9 +132,9 @@ export function validateToken(accessToken: string, machineId: string): boolean { return false; } - // Machine ID format validation (should be UUID-like) - const uuidRegex = /^[a-f0-9-]{32,}$/i; - if (!uuidRegex.test(machineId.replace(/-/g, ''))) { + // Machine ID format validation (UUID without hyphens = exactly 32 hex chars) + const hexRegex = /^[a-f0-9]{32}$/i; + if (!hexRegex.test(machineId.replace(/-/g, ''))) { return false; } diff --git a/src/cursor/types.ts b/src/cursor/types.ts index 10c33ad2..9418fe70 100644 --- a/src/cursor/types.ts +++ b/src/cursor/types.ts @@ -1,7 +1,7 @@ /** * Cursor IDE Type Definitions * - * TypeScript interfaces for the Cursor module. + * TypeScript interfaces for the Cursor auth module. */ /** @@ -36,98 +36,6 @@ export interface CursorAuthStatus { expired?: boolean; } -/** - * Cursor daemon/process status - */ -export interface CursorDaemonStatus { - /** Whether daemon is running */ - running: boolean; - /** Port number daemon is listening on */ - port: number; - /** Process ID (if available) */ - pid?: number; -} - -/** - * Cursor AI model - */ -export interface CursorModel { - /** Model ID */ - id: string; - /** Display name */ - name: string; - /** Provider (e.g., 'openai', 'anthropic') */ - provider: string; - /** Whether this is the default model */ - isDefault?: boolean; -} - -/** - * Message role - */ -export type MessageRole = 'user' | 'assistant'; - -/** - * Cursor message for protobuf - */ -export interface CursorMessage { - /** Message role */ - role: MessageRole; - /** Message content */ - content: string; - /** Tool calls (if any) */ - tool_calls?: CursorToolCall[]; - /** Tool results (if any) */ - tool_results?: CursorToolResult[]; -} - -/** - * Cursor tool call - */ -export interface CursorToolCall { - /** Unique ID for this tool call */ - id: string; - /** Type of tool call */ - type: 'function'; - /** Function details */ - function: { - /** Function name */ - name: string; - /** JSON-encoded arguments */ - arguments: string; - }; - /** Whether this is the last tool call in sequence */ - isLast?: boolean; -} - -/** - * Cursor tool result - */ -export interface CursorToolResult { - /** ID of the tool call this result is for */ - tool_call_id: string; - /** Tool name */ - name: string; - /** Result index */ - index: number; - /** Raw arguments */ - raw_args: string; -} - -/** - * Result from protobuf extraction - */ -export interface ProtobufExtractResult { - /** Extracted text content */ - text: string | null; - /** Error message (if extraction failed) */ - error: string | null; - /** Extracted tool call (if any) */ - toolCall: CursorToolCall | null; - /** Thinking/reasoning content (if any) */ - thinking: string | null; -} - /** * Auto-detection result */ From b412ba2a9eb6e92630de3c4b98bb95a1375927a0 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 12 Feb 2026 01:07:25 +0700 Subject: [PATCH 4/6] fix(cursor): address remaining review items in auth module - Enhanced extractUserInfo to return JWT exp claim for expiry detection - Updated checkAuthStatus to use JWT exp when available, fallback to importedAt heuristic - Added typeof validation for loadCredentials fields - Added ENOENT detection for sqlite3 availability in queryStateDb - Added deleteCredentials function for cleanup - Updated PR #526 description to remove refreshToken mention --- src/cursor/cursor-auth.ts | 67 ++++++++++++++++++++++++++++++++------- 1 file changed, 56 insertions(+), 11 deletions(-) diff --git a/src/cursor/cursor-auth.ts b/src/cursor/cursor-auth.ts index 7cf7b355..460d044f 100644 --- a/src/cursor/cursor-auth.ts +++ b/src/cursor/cursor-auth.ts @@ -60,7 +60,12 @@ function queryStateDb(dbPath: string, key: string): string | null { { encoding: 'utf8', timeout: 5000, stdio: ['pipe', 'pipe', 'ignore'] } ).trim(); return result || null; - } catch { + } catch (err) { + // Check if sqlite3 is not installed + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + // sqlite3 not found - could log this if needed + return null; + } return null; } } @@ -145,7 +150,9 @@ export function validateToken(accessToken: string, machineId: string): boolean { * Extract user info from token if possible * Cursor tokens may contain encoded user info as JWT */ -export function extractUserInfo(accessToken: string): { email?: string; userId?: string } | null { +export function extractUserInfo( + accessToken: string +): { email?: string; userId?: string; exp?: number } | null { try { // Try to decode as JWT const parts = accessToken.split('.'); @@ -161,6 +168,7 @@ export function extractUserInfo(accessToken: string): { email?: string; userId?: return { email: decoded.email || decoded.sub, userId: decoded.sub || decoded.user_id, + exp: decoded.exp, }; } } catch { @@ -219,6 +227,16 @@ export function loadCredentials(): CursorCredentials | null { 'authMethod' in parsed && 'importedAt' in parsed ) { + // Type validation + if ( + typeof parsed.accessToken !== 'string' || + typeof parsed.machineId !== 'string' || + typeof parsed.importedAt !== 'string' || + (parsed.authMethod !== 'auto-detect' && parsed.authMethod !== 'manual') + ) { + return null; + } + return parsed as CursorCredentials; } @@ -243,18 +261,27 @@ export function checkAuthStatus(): CursorAuthStatus { return { authenticated: false }; } - // Calculate token age in hours + // Try to get token expiry from JWT exp claim let tokenAge: number | undefined; let expired = false; - const TOKEN_EXPIRY_HOURS = 24; + const userInfo = extractUserInfo(credentials.accessToken); - try { - const importedDate = new Date(credentials.importedAt); - const now = new Date(); - tokenAge = Math.floor((now.getTime() - importedDate.getTime()) / (1000 * 60 * 60)); - expired = tokenAge >= TOKEN_EXPIRY_HOURS; - } catch { - // Invalid date format + if (userInfo?.exp) { + // Use JWT exp claim (Unix timestamp in seconds) + const now = Math.floor(Date.now() / 1000); + expired = now >= userInfo.exp; + tokenAge = Math.floor((now - (userInfo.exp - 24 * 60 * 60)) / (60 * 60)); // Assume 24h token + } else { + // Fallback to importedAt heuristic + const TOKEN_EXPIRY_HOURS = 24; + try { + const importedDate = new Date(credentials.importedAt); + const now = new Date(); + tokenAge = Math.floor((now.getTime() - importedDate.getTime()) / (1000 * 60 * 60)); + expired = tokenAge >= TOKEN_EXPIRY_HOURS; + } catch { + // Invalid date format + } } return { @@ -264,3 +291,21 @@ export function checkAuthStatus(): CursorAuthStatus { expired, }; } + +/** + * Delete credentials file + */ +export function deleteCredentials(): boolean { + const credPath = getCredentialsPath(); + + if (!fs.existsSync(credPath)) { + return false; + } + + try { + fs.unlinkSync(credPath); + return true; + } catch { + return false; + } +} From 84a256d0ac8ac2c3be6c4ffdbd422eda47f3abcc Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 12 Feb 2026 01:23:29 +0700 Subject: [PATCH 5/6] fix(cursor): address second-round review feedback for auth module - Add comprehensive unit tests for cursor-auth.test.ts - validateToken: valid/invalid tokens, short tokens, UUID formats, empty strings - extractUserInfo: JWT parsing, email handling, non-JWT tokens, malformed base64 - saveCredentials/loadCredentials: round-trip, invalid JSON/types, missing fields - checkAuthStatus: authenticated/not authenticated, expired tokens, JWT exp, invalid dates - deleteCredentials: delete existing/non-existent files, multiple deletes - All tests use CCS_HOME env var for isolation, real file I/O, no mocks - Fix dead try-catch around new Date() in checkAuthStatus() - Replace try-catch with isNaN check (new Date('garbage') returns Invalid Date, not throw) - Properly handle Invalid Date by checking isNaN(getTime()) - Fix email populated with sub claim in extractUserInfo() - Change email: decoded.email || decoded.sub to email: decoded.email || undefined - Prevent non-email values (UUIDs) from populating email field - Add type guards for JSON.parse result in extractUserInfo() - Cast to Record and validate types - Use typeof checks for email, userId, exp fields --- src/cursor/cursor-auth.ts | 19 +- tests/unit/cursor/cursor-auth.test.ts | 388 ++++++++++++++++++++++++++ 2 files changed, 399 insertions(+), 8 deletions(-) create mode 100644 tests/unit/cursor/cursor-auth.test.ts diff --git a/src/cursor/cursor-auth.ts b/src/cursor/cursor-auth.ts index 460d044f..58d6d8da 100644 --- a/src/cursor/cursor-auth.ts +++ b/src/cursor/cursor-auth.ts @@ -164,11 +164,16 @@ export function extractUserInfo( } const decoded = JSON.parse( Buffer.from(payload.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString() - ); + ) as Record; return { - email: decoded.email || decoded.sub, - userId: decoded.sub || decoded.user_id, - exp: decoded.exp, + email: typeof decoded.email === 'string' ? decoded.email : undefined, + userId: + typeof decoded.sub === 'string' + ? decoded.sub + : typeof decoded.user_id === 'string' + ? decoded.user_id + : undefined, + exp: typeof decoded.exp === 'number' ? decoded.exp : undefined, }; } } catch { @@ -274,13 +279,11 @@ export function checkAuthStatus(): CursorAuthStatus { } else { // Fallback to importedAt heuristic const TOKEN_EXPIRY_HOURS = 24; - try { - const importedDate = new Date(credentials.importedAt); + const importedDate = new Date(credentials.importedAt); + if (!isNaN(importedDate.getTime())) { const now = new Date(); tokenAge = Math.floor((now.getTime() - importedDate.getTime()) / (1000 * 60 * 60)); expired = tokenAge >= TOKEN_EXPIRY_HOURS; - } catch { - // Invalid date format } } diff --git a/tests/unit/cursor/cursor-auth.test.ts b/tests/unit/cursor/cursor-auth.test.ts new file mode 100644 index 00000000..e66e2901 --- /dev/null +++ b/tests/unit/cursor/cursor-auth.test.ts @@ -0,0 +1,388 @@ +/** + * Unit tests for Cursor authentication module + */ + +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import type { CursorCredentials } from '../../../src/cursor/types'; +import { + validateToken, + extractUserInfo, + saveCredentials, + loadCredentials, + checkAuthStatus, + deleteCredentials, +} from '../../../src/cursor/cursor-auth'; + +// Test isolation +let originalCcsHome: string | undefined; +let tempDir: string; + +beforeEach(() => { + // Save original CCS_HOME + originalCcsHome = process.env.CCS_HOME; + + // Create temp directory for test isolation + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-cursor-test-')); + process.env.CCS_HOME = tempDir; +}); + +afterEach(() => { + // Restore original CCS_HOME + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + + // Clean up temp directory + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +describe('validateToken', () => { + it('should accept valid token and machineId', () => { + const token = 'a'.repeat(50); // 50 chars minimum + const machineId = 'a'.repeat(32); // 32 hex chars + expect(validateToken(token, machineId)).toBe(true); + }); + + it('should reject 49-char token (too short)', () => { + const token = 'a'.repeat(49); // Just under minimum + const machineId = 'a'.repeat(32); + expect(validateToken(token, machineId)).toBe(false); + }); + + it('should reject 31-char hex UUID (too short)', () => { + const token = 'a'.repeat(50); + const machineId = 'a'.repeat(31); // Just under 32 + expect(validateToken(token, machineId)).toBe(false); + }); + + it('should accept UUID with hyphens (strips them)', () => { + const token = 'a'.repeat(50); + const machineId = '12345678-1234-1234-1234-123456789abc'; // 36 chars with hyphens + expect(validateToken(token, machineId)).toBe(true); + }); + + it('should reject empty token', () => { + const machineId = 'a'.repeat(32); + expect(validateToken('', machineId)).toBe(false); + }); + + it('should reject empty machineId', () => { + const token = 'a'.repeat(50); + expect(validateToken(token, '')).toBe(false); + }); + + it('should reject non-hex characters in machineId', () => { + const token = 'a'.repeat(50); + const machineId = 'g'.repeat(32); // 'g' is not valid hex + expect(validateToken(token, machineId)).toBe(false); + }); +}); + +describe('extractUserInfo', () => { + it('should extract email and sub from valid JWT', () => { + // JWT: {"email":"user@example.com","sub":"12345","exp":1234567890} + const payload = Buffer.from( + JSON.stringify({ email: 'user@example.com', sub: '12345', exp: 1234567890 }) + ).toString('base64'); + const token = `header.${payload}.signature`; + + const result = extractUserInfo(token); + expect(result).toEqual({ + email: 'user@example.com', + userId: '12345', + exp: 1234567890, + }); + }); + + it('should return undefined email when only sub claim exists', () => { + // JWT: {"sub":"uuid-12345","exp":1234567890} + const payload = Buffer.from( + JSON.stringify({ sub: 'uuid-12345', exp: 1234567890 }) + ).toString('base64'); + const token = `header.${payload}.signature`; + + const result = extractUserInfo(token); + expect(result).toEqual({ + email: undefined, + userId: 'uuid-12345', + exp: 1234567890, + }); + }); + + it('should return null for non-JWT token', () => { + const token = 'a'.repeat(50); // Plain token + const result = extractUserInfo(token); + expect(result).toBe(null); + }); + + it('should return null for malformed base64', () => { + const token = 'header.!!!invalid-base64!!!.signature'; + const result = extractUserInfo(token); + expect(result).toBe(null); + }); + + it('should handle JWT with user_id instead of sub', () => { + // JWT: {"email":"user@example.com","user_id":"67890"} + const payload = Buffer.from( + JSON.stringify({ email: 'user@example.com', user_id: '67890' }) + ).toString('base64'); + const token = `header.${payload}.signature`; + + const result = extractUserInfo(token); + expect(result).toEqual({ + email: 'user@example.com', + userId: '67890', + exp: undefined, + }); + }); +}); + +describe('saveCredentials and loadCredentials', () => { + it('should save and load credentials successfully', () => { + const credentials: CursorCredentials = { + accessToken: 'a'.repeat(50), + machineId: 'b'.repeat(32), + authMethod: 'auto-detect', + importedAt: new Date().toISOString(), + }; + + saveCredentials(credentials); + const loaded = loadCredentials(); + + expect(loaded).toEqual(credentials); + }); + + it('should return null when no credentials file exists', () => { + const loaded = loadCredentials(); + expect(loaded).toBe(null); + }); + + it('should create directory with restrictive permissions', () => { + const credentials: CursorCredentials = { + accessToken: 'a'.repeat(50), + machineId: 'b'.repeat(32), + authMethod: 'manual', + importedAt: new Date().toISOString(), + }; + + saveCredentials(credentials); + + // CCS_HOME is set to tempDir, but getCcsDir() appends '.ccs' to it + const credDir = path.join(tempDir, '.ccs', 'cursor'); + expect(fs.existsSync(credDir)).toBe(true); + + // Check directory permissions (skip on Windows) + if (process.platform !== 'win32') { + const stats = fs.statSync(credDir); + const mode = stats.mode & 0o777; + expect(mode).toBe(0o700); + } + }); + + it('should return null for invalid JSON in credentials file', () => { + const credPath = path.join(tempDir, 'cursor', 'credentials.json'); + fs.mkdirSync(path.dirname(credPath), { recursive: true }); + fs.writeFileSync(credPath, 'invalid json{{{'); + + const loaded = loadCredentials(); + expect(loaded).toBe(null); + }); + + it('should return null for credentials missing required fields', () => { + const credPath = path.join(tempDir, 'cursor', 'credentials.json'); + fs.mkdirSync(path.dirname(credPath), { recursive: true }); + fs.writeFileSync( + credPath, + JSON.stringify({ + accessToken: 'token', + // Missing machineId, authMethod, importedAt + }) + ); + + const loaded = loadCredentials(); + expect(loaded).toBe(null); + }); + + it('should return null for credentials with wrong types', () => { + const credPath = path.join(tempDir, 'cursor', 'credentials.json'); + fs.mkdirSync(path.dirname(credPath), { recursive: true }); + fs.writeFileSync( + credPath, + JSON.stringify({ + accessToken: 123, // Wrong type (number instead of string) + machineId: 'abc', + authMethod: 'auto-detect', + importedAt: new Date().toISOString(), + }) + ); + + const loaded = loadCredentials(); + expect(loaded).toBe(null); + }); + + it('should return null for invalid authMethod', () => { + const credPath = path.join(tempDir, 'cursor', 'credentials.json'); + fs.mkdirSync(path.dirname(credPath), { recursive: true }); + fs.writeFileSync( + credPath, + JSON.stringify({ + accessToken: 'token', + machineId: 'abc', + authMethod: 'invalid-method', // Invalid authMethod + importedAt: new Date().toISOString(), + }) + ); + + const loaded = loadCredentials(); + expect(loaded).toBe(null); + }); +}); + +describe('checkAuthStatus', () => { + it('should return not authenticated when no credentials exist', () => { + const status = checkAuthStatus(); + expect(status.authenticated).toBe(false); + expect(status.credentials).toBeUndefined(); + }); + + it('should return authenticated for valid credentials', () => { + const credentials: CursorCredentials = { + accessToken: 'a'.repeat(50), + machineId: 'b'.repeat(32), + authMethod: 'auto-detect', + importedAt: new Date().toISOString(), + }; + + saveCredentials(credentials); + const status = checkAuthStatus(); + + expect(status.authenticated).toBe(true); + expect(status.credentials).toEqual(credentials); + expect(status.expired).toBe(false); + expect(status.tokenAge).toBeDefined(); + expect(status.tokenAge).toBeLessThan(1); // Just imported + }); + + it('should detect expired credentials (importedAt > 24h ago)', () => { + // Create credentials from 25 hours ago + const past = new Date(); + past.setHours(past.getHours() - 25); + + const credentials: CursorCredentials = { + accessToken: 'a'.repeat(50), + machineId: 'b'.repeat(32), + authMethod: 'manual', + importedAt: past.toISOString(), + }; + + saveCredentials(credentials); + const status = checkAuthStatus(); + + expect(status.authenticated).toBe(true); + expect(status.expired).toBe(true); + expect(status.tokenAge).toBeGreaterThanOrEqual(24); + }); + + it('should return not authenticated for invalid token format', () => { + const credentials: CursorCredentials = { + accessToken: 'short', // Invalid (too short) + machineId: 'b'.repeat(32), + authMethod: 'manual', + importedAt: new Date().toISOString(), + }; + + saveCredentials(credentials); + const status = checkAuthStatus(); + + expect(status.authenticated).toBe(false); + }); + + it('should use JWT exp claim when available', () => { + // Create JWT token that expired 1 hour ago + const expiredTime = Math.floor(Date.now() / 1000) - 3600; + const payload = Buffer.from( + JSON.stringify({ email: 'test@example.com', sub: '123', exp: expiredTime }) + ).toString('base64'); + const jwtToken = `header.${payload}.signature`; + + const credentials: CursorCredentials = { + accessToken: jwtToken, + machineId: 'b'.repeat(32), + authMethod: 'auto-detect', + importedAt: new Date().toISOString(), // Recent import + }; + + saveCredentials(credentials); + const status = checkAuthStatus(); + + expect(status.authenticated).toBe(true); + expect(status.expired).toBe(true); // Should detect expiry from JWT exp + }); + + it('should handle invalid importedAt date gracefully', () => { + // Create credentials with valid format but garbage date value + const credentials: CursorCredentials = { + accessToken: 'a'.repeat(50), + machineId: 'b'.repeat(32), + authMethod: 'manual', + importedAt: 'invalid-date-garbage-2026-99-99T99:99:99Z', + }; + + saveCredentials(credentials); + const status = checkAuthStatus(); + + // Should still authenticate if token format is valid + expect(status.authenticated).toBe(true); + // tokenAge should be undefined due to invalid date (NaN from getTime()) + expect(status.tokenAge).toBeUndefined(); + // expired should be false (defaults to false when date parsing fails) + expect(status.expired).toBe(false); + }); +}); + +describe('deleteCredentials', () => { + it('should delete existing credentials file and return true', () => { + const credentials: CursorCredentials = { + accessToken: 'a'.repeat(50), + machineId: 'b'.repeat(32), + authMethod: 'auto-detect', + importedAt: new Date().toISOString(), + }; + + saveCredentials(credentials); + expect(loadCredentials()).not.toBe(null); + + const result = deleteCredentials(); + expect(result).toBe(true); + expect(loadCredentials()).toBe(null); + }); + + it('should return false when credentials file does not exist', () => { + const result = deleteCredentials(); + expect(result).toBe(false); + }); + + it('should handle multiple delete calls gracefully', () => { + const credentials: CursorCredentials = { + accessToken: 'a'.repeat(50), + machineId: 'b'.repeat(32), + authMethod: 'manual', + importedAt: new Date().toISOString(), + }; + + saveCredentials(credentials); + + // First delete should succeed + expect(deleteCredentials()).toBe(true); + + // Second delete should return false (already deleted) + expect(deleteCredentials()).toBe(false); + }); +}); From a8a68c95992614e7c7ea1e3f65c85e29713ed746 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 12 Feb 2026 03:09:35 +0700 Subject: [PATCH 6/6] fix(cursor): address third-round review feedback for auth module --- src/cursor/cursor-auth.ts | 52 ++++++++--------- tests/unit/cursor/cursor-auth.test.ts | 82 +++++++++++++++++++++++---- 2 files changed, 97 insertions(+), 37 deletions(-) diff --git a/src/cursor/cursor-auth.ts b/src/cursor/cursor-auth.ts index 58d6d8da..7440e983 100644 --- a/src/cursor/cursor-auth.ts +++ b/src/cursor/cursor-auth.ts @@ -165,16 +165,20 @@ export function extractUserInfo( const decoded = JSON.parse( Buffer.from(payload.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString() ) as Record; - return { - email: typeof decoded.email === 'string' ? decoded.email : undefined, - userId: - typeof decoded.sub === 'string' - ? decoded.sub - : typeof decoded.user_id === 'string' - ? decoded.user_id - : undefined, - exp: typeof decoded.exp === 'number' ? decoded.exp : undefined, - }; + + const email = typeof decoded.email === 'string' ? decoded.email : undefined; + const userId = + typeof decoded.sub === 'string' + ? decoded.sub + : typeof decoded.user_id === 'string' + ? decoded.user_id + : undefined; + const exp = typeof decoded.exp === 'number' ? decoded.exp : undefined; + + // If all claims are undefined, treat as if JWT parsing failed + if (!email && !userId && exp === undefined) return null; + + return { email, userId, exp }; } } catch { // Token is not a JWT, that's okay @@ -272,17 +276,19 @@ export function checkAuthStatus(): CursorAuthStatus { const userInfo = extractUserInfo(credentials.accessToken); if (userInfo?.exp) { - // Use JWT exp claim (Unix timestamp in seconds) + // Use JWT exp claim for expiry detection const now = Math.floor(Date.now() / 1000); expired = now >= userInfo.exp; - tokenAge = Math.floor((now - (userInfo.exp - 24 * 60 * 60)) / (60 * 60)); // Assume 24h token - } else { - // Fallback to importedAt heuristic - const TOKEN_EXPIRY_HOURS = 24; - const importedDate = new Date(credentials.importedAt); - if (!isNaN(importedDate.getTime())) { - const now = new Date(); - tokenAge = Math.floor((now.getTime() - importedDate.getTime()) / (1000 * 60 * 60)); + } + + // Always use importedAt for tokenAge (more reliable than reverse-engineering JWT lifetime) + const TOKEN_EXPIRY_HOURS = 24; + const importedDate = new Date(credentials.importedAt); + if (!isNaN(importedDate.getTime())) { + const now = new Date(); + tokenAge = Math.floor((now.getTime() - importedDate.getTime()) / (1000 * 60 * 60)); + // Only set expired from importedAt if JWT exp was not available + if (userInfo?.exp === undefined) { expired = tokenAge >= TOKEN_EXPIRY_HOURS; } } @@ -299,14 +305,8 @@ export function checkAuthStatus(): CursorAuthStatus { * Delete credentials file */ export function deleteCredentials(): boolean { - const credPath = getCredentialsPath(); - - if (!fs.existsSync(credPath)) { - return false; - } - try { - fs.unlinkSync(credPath); + fs.unlinkSync(getCredentialsPath()); return true; } catch { return false; diff --git a/tests/unit/cursor/cursor-auth.test.ts b/tests/unit/cursor/cursor-auth.test.ts index e66e2901..8743fbe6 100644 --- a/tests/unit/cursor/cursor-auth.test.ts +++ b/tests/unit/cursor/cursor-auth.test.ts @@ -14,6 +14,7 @@ import { loadCredentials, checkAuthStatus, deleteCredentials, + autoDetectTokens, } from '../../../src/cursor/cursor-auth'; // Test isolation @@ -103,9 +104,9 @@ describe('extractUserInfo', () => { it('should return undefined email when only sub claim exists', () => { // JWT: {"sub":"uuid-12345","exp":1234567890} - const payload = Buffer.from( - JSON.stringify({ sub: 'uuid-12345', exp: 1234567890 }) - ).toString('base64'); + const payload = Buffer.from(JSON.stringify({ sub: 'uuid-12345', exp: 1234567890 })).toString( + 'base64' + ); const token = `header.${payload}.signature`; const result = extractUserInfo(token); @@ -142,6 +143,15 @@ describe('extractUserInfo', () => { exp: undefined, }); }); + + it('should return null for JWT with no meaningful claims', () => { + // JWT: {"iat":1234567890} (only issued-at, no email/sub/exp) + const payload = Buffer.from(JSON.stringify({ iat: 1234567890 })).toString('base64'); + const token = `header.${payload}.signature`; + + const result = extractUserInfo(token); + expect(result).toBe(null); + }); }); describe('saveCredentials and loadCredentials', () => { @@ -187,8 +197,10 @@ describe('saveCredentials and loadCredentials', () => { }); it('should return null for invalid JSON in credentials file', () => { - const credPath = path.join(tempDir, 'cursor', 'credentials.json'); - fs.mkdirSync(path.dirname(credPath), { recursive: true }); + // CCS_HOME is set to tempDir, getCcsDir() returns path.join(tempDir, '.ccs') + const credDir = path.join(tempDir, '.ccs', 'cursor'); + const credPath = path.join(credDir, 'credentials.json'); + fs.mkdirSync(credDir, { recursive: true }); fs.writeFileSync(credPath, 'invalid json{{{'); const loaded = loadCredentials(); @@ -196,8 +208,9 @@ describe('saveCredentials and loadCredentials', () => { }); it('should return null for credentials missing required fields', () => { - const credPath = path.join(tempDir, 'cursor', 'credentials.json'); - fs.mkdirSync(path.dirname(credPath), { recursive: true }); + const credDir = path.join(tempDir, '.ccs', 'cursor'); + const credPath = path.join(credDir, 'credentials.json'); + fs.mkdirSync(credDir, { recursive: true }); fs.writeFileSync( credPath, JSON.stringify({ @@ -211,8 +224,9 @@ describe('saveCredentials and loadCredentials', () => { }); it('should return null for credentials with wrong types', () => { - const credPath = path.join(tempDir, 'cursor', 'credentials.json'); - fs.mkdirSync(path.dirname(credPath), { recursive: true }); + const credDir = path.join(tempDir, '.ccs', 'cursor'); + const credPath = path.join(credDir, 'credentials.json'); + fs.mkdirSync(credDir, { recursive: true }); fs.writeFileSync( credPath, JSON.stringify({ @@ -228,8 +242,9 @@ describe('saveCredentials and loadCredentials', () => { }); it('should return null for invalid authMethod', () => { - const credPath = path.join(tempDir, 'cursor', 'credentials.json'); - fs.mkdirSync(path.dirname(credPath), { recursive: true }); + const credDir = path.join(tempDir, '.ccs', 'cursor'); + const credPath = path.join(credDir, 'credentials.json'); + fs.mkdirSync(credDir, { recursive: true }); fs.writeFileSync( credPath, JSON.stringify({ @@ -386,3 +401,48 @@ describe('deleteCredentials', () => { expect(deleteCredentials()).toBe(false); }); }); + +describe('autoDetectTokens', () => { + it('should return not found for Windows platform', () => { + // Save original platform + const originalPlatform = process.platform; + + // Mock Windows platform + Object.defineProperty(process, 'platform', { + value: 'win32', + configurable: true, + }); + + const result = autoDetectTokens(); + + expect(result.found).toBe(false); + expect(result.error).toContain('not supported on Windows'); + + // Restore original platform + Object.defineProperty(process, 'platform', { + value: originalPlatform, + configurable: true, + }); + }); + + it('should return not found when database file does not exist', () => { + // Skip on Windows (already covered by previous test) + if (process.platform === 'win32') { + return; + } + + const result = autoDetectTokens(); + + // Should fail because Cursor database doesn't exist in test environment + expect(result.found).toBe(false); + expect(result.error).toBeDefined(); + }); + + it('should have found property in return type', () => { + const result = autoDetectTokens(); + + // Verify return type structure + expect(result).toHaveProperty('found'); + expect(typeof result.found).toBe('boolean'); + }); +});