mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-14 08:18:04 +00:00
Merge pull request #526 from kaitranntt/kai/feat/519-cursor-auth-module
feat(cursor): add credential detection, SQLite token extraction, and auth status
This commit is contained in:
3 files changed
+813
No files matched your search
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* 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 { execFileSync } 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 {
|
||||
// Escape single quotes to prevent SQL injection
|
||||
const sanitizedKey = key.replace(/'/g, "''");
|
||||
const result = execFileSync(
|
||||
'sqlite3',
|
||||
[dbPath, `SELECT value FROM itemTable WHERE key='${sanitizedKey}'`],
|
||||
{ encoding: 'utf8', timeout: 5000, stdio: ['pipe', 'pipe', 'ignore'] }
|
||||
).trim();
|
||||
return result || null;
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
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 (UUID without hyphens = exactly 32 hex chars)
|
||||
const hexRegex = /^[a-f0-9]{32}$/i;
|
||||
if (!hexRegex.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; exp?: number } | 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()
|
||||
) as Record<string, unknown>;
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 with restrictive permissions
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
|
||||
// Write credentials with restrictive permissions
|
||||
fs.writeFileSync(credPath, JSON.stringify(credentials, null, 2), {
|
||||
encoding: 'utf8',
|
||||
mode: 0o600,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
) {
|
||||
// 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;
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
// Try to get token expiry from JWT exp claim
|
||||
let tokenAge: number | undefined;
|
||||
let expired = false;
|
||||
const userInfo = extractUserInfo(credentials.accessToken);
|
||||
|
||||
if (userInfo?.exp) {
|
||||
// Use JWT exp claim for expiry detection
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
expired = now >= userInfo.exp;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
authenticated: true,
|
||||
credentials,
|
||||
tokenAge,
|
||||
expired,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete credentials file
|
||||
*/
|
||||
export function deleteCredentials(): boolean {
|
||||
try {
|
||||
fs.unlinkSync(getCredentialsPath());
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Cursor IDE Type Definitions
|
||||
*
|
||||
* TypeScript interfaces for the Cursor auth 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;
|
||||
/** Whether token has expired (>24 hours old) */
|
||||
expired?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
/**
|
||||
* 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,
|
||||
autoDetectTokens,
|
||||
} 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,
|
||||
});
|
||||
});
|
||||
|
||||
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', () => {
|
||||
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', () => {
|
||||
// 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();
|
||||
expect(loaded).toBe(null);
|
||||
});
|
||||
|
||||
it('should return null for credentials missing required fields', () => {
|
||||
const credDir = path.join(tempDir, '.ccs', 'cursor');
|
||||
const credPath = path.join(credDir, 'credentials.json');
|
||||
fs.mkdirSync(credDir, { 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 credDir = path.join(tempDir, '.ccs', 'cursor');
|
||||
const credPath = path.join(credDir, 'credentials.json');
|
||||
fs.mkdirSync(credDir, { 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 credDir = path.join(tempDir, '.ccs', 'cursor');
|
||||
const credPath = path.join(credDir, 'credentials.json');
|
||||
fs.mkdirSync(credDir, { 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);
|
||||
});
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user