UNPKG

@juspay/neurolink

Version:

Universal AI Development Platform with working MCP integration, multi-provider support, voice (TTS/STT/realtime), and professional CLI. 58+ external MCP servers discoverable, multimodal file processing, RAG pipelines. Build, test, and deploy AI applicatio

843 lines (842 loc) 33.5 kB
/** * NeuroLink OAuth Token Store * * Secure storage for OAuth tokens with encoding support and multi-provider capability. * Stores tokens in the user's home directory with restrictive permissions. * * Features: * - Multi-provider token storage in a single tokens.json file * - Secure file storage with 0o600 permissions * - Token expiration checking with configurable buffer * - Simple XOR-based obfuscation (not encryption, but not plaintext) * - Automatic token refresh via configurable refresher functions * - Cross-platform support (Unix/macOS/Windows) */ import { promises as fs } from "fs"; import { join } from "path"; import { homedir } from "os"; import { createHash } from "crypto"; import { logger } from "../utils/logger.js"; import { TokenStoreError } from "../types/index.js"; import { AsyncMutex } from "../utils/asyncMutex.js"; import { withSpan } from "../telemetry/withSpan.js"; import { tracers } from "../telemetry/tracers.js"; const { readFile, writeFile, mkdir, unlink, access, chmod, rename } = fs; // ============================================================================= // TOKEN STORE CLASS // ============================================================================= /** * Secure token storage for OAuth tokens with multi-provider support * * Provides persistent storage for OAuth tokens with: * - Multi-provider support in a single file * - Secure file permissions (0o600) * - Optional obfuscation/encoding * - Token expiration checking with buffer * - Automatic token refresh via configurable refreshers * * @example * ```typescript * const store = new TokenStore(); * * // Save tokens for a provider * await store.saveTokens("anthropic", { * accessToken: "sk-...", * refreshToken: "rt-...", * expiresAt: Date.now() + 3600000, * tokenType: "Bearer", * }); * * // Set up automatic refresh * store.setTokenRefresher("anthropic", async (refreshToken) => { * // Call OAuth refresh endpoint * return newTokens; * }); * * // Get a valid token (auto-refreshes if needed) * const token = await store.getValidToken("anthropic"); * ``` */ export class TokenStore { static STORAGE_VERSION = "2.0"; static NEUROLINK_DIR = ".neurolink"; static TOKEN_FILE = "tokens.json"; static FILE_PERMISSIONS = 0o600; static DIR_PERMISSIONS = 0o700; /** Default expiration buffer: 1 hour (proactive refresh before actual expiry) */ static DEFAULT_EXPIRY_BUFFER_MS = 60 * 60 * 1000; storagePath; encryptionEnabled; encryptionKey; tokenRefreshers = new Map(); inFlightRefreshes = new Map(); _mutex = new AsyncMutex(); /** * Creates a new TokenStore instance * * @param options - Configuration options * @param options.encryptionEnabled - Whether to enable token obfuscation (default: true) * @param options.customStoragePath - Override the default storage path */ constructor(options = {}) { const { encryptionEnabled = true, customStoragePath } = options; this.encryptionEnabled = encryptionEnabled; this.encryptionKey = this.deriveEncryptionKey(); if (customStoragePath) { this.storagePath = customStoragePath; } else { this.storagePath = join(homedir(), TokenStore.NEUROLINK_DIR, TokenStore.TOKEN_FILE); } } // =========================================================================== // PUBLIC METHODS // =========================================================================== /** * Gets the path where tokens are stored * * @returns The absolute path to the token storage file */ getStoragePath() { return this.storagePath; } /** * Saves OAuth tokens for a specific provider * * @param provider - The provider name (e.g., "anthropic", "openai") * @param tokens - The OAuth tokens to save * @throws TokenStoreError if storage fails */ async saveTokens(provider, tokens) { return withSpan({ name: "neurolink.auth.token.save", tracer: tracers.auth, attributes: { "auth.provider": provider, "auth.has_refresh_token": Boolean(tokens.refreshToken), "auth.token_type": tokens.tokenType, }, }, async () => this._mutex.runExclusive(async () => { await this._saveTokensInternal(provider, tokens); })); } /** * Internal save without mutex — callers must already hold the mutex. */ async _saveTokensInternal(provider, tokens) { // Validate tokens before saving this.validateTokens(tokens); const storageDir = join(this.storagePath, ".."); await this.ensureDirectory(storageDir); // Load existing data or create new let storageData; try { storageData = await this.loadStorageData(); } catch (error) { if (error instanceof TokenStoreError && error.code === "NOT_FOUND") { // Create new storage structure storageData = { version: TokenStore.STORAGE_VERSION, lastModified: Date.now(), providers: {}, }; } else { throw error; } } // Update provider tokens storageData.providers[provider] = { tokens, createdAt: Date.now(), lastAccessed: Date.now(), }; storageData.lastModified = Date.now(); try { const content = this.encryptionEnabled ? this.obfuscate(JSON.stringify(storageData)) : JSON.stringify(storageData, null, 2); // Write to temporary file first for atomic operation // Use PID-scoped temp file to avoid cross-process race conditions const tempPath = `${this.storagePath}.tmp.${process.pid}`; await writeFile(tempPath, content, "utf-8"); // Set restrictive permissions before moving to final location await chmod(tempPath, TokenStore.FILE_PERMISSIONS); // Rename to final location (atomic on most filesystems) await fs.rename(tempPath, this.storagePath); logger.debug("OAuth tokens saved successfully", { provider, path: this.storagePath, encrypted: this.encryptionEnabled, }); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); logger.error("Failed to save OAuth tokens", { provider, error: errorMessage, }); throw new TokenStoreError(`Failed to save tokens for ${provider}: ${errorMessage}`, "STORAGE_ERROR"); } } /** * Loads stored OAuth tokens for a specific provider * * @param provider - The provider name (e.g., "anthropic", "openai") * @returns The stored tokens, or null if not found * @throws TokenStoreError if reading fails (other than file not found) */ async loadTokens(provider) { return withSpan({ name: "neurolink.auth.token.load", tracer: tracers.auth, attributes: { "auth.provider": provider }, }, async (span) => { const result = await this._mutex.runExclusive(async () => { return this._loadTokensInternal(provider); }); span.setAttribute("auth.found", result !== null); if (result) { span.setAttribute("auth.expired", this.isTokenExpired(result, 0)); } return result; }); } /** * Internal load without mutex — callers must already hold the mutex. */ async _loadTokensInternal(provider) { try { const storageData = await this.loadStorageData(); const providerData = storageData.providers[provider]; if (!providerData) { logger.debug("No stored tokens found for provider", { provider }); return null; } // Update last accessed time providerData.lastAccessed = Date.now(); await this.saveStorageData(storageData); logger.debug("OAuth tokens retrieved successfully", { provider, expiresAt: new Date(providerData.tokens.expiresAt).toISOString(), isExpired: this.isTokenExpired(providerData.tokens), }); return providerData.tokens; } catch (error) { if (error instanceof TokenStoreError && error.code === "NOT_FOUND") { return null; } throw error; } } /** * Clears stored tokens for a specific provider * * @param provider - The provider name (e.g., "anthropic", "openai") * @throws TokenStoreError if deletion fails */ async clearTokens(provider) { return withSpan({ name: "neurolink.auth.token.clear", tracer: tracers.auth, attributes: { "auth.provider": provider }, }, async () => this._clearTokensImpl(provider)); } async _clearTokensImpl(provider) { return this._mutex.runExclusive(async () => { // Clear in-memory refresh state so re-adding an account starts fresh this.inFlightRefreshes.delete(provider); this.tokenRefreshers.delete(provider); try { const storageData = await this.loadStorageData(); if (!storageData.providers[provider]) { logger.debug("No tokens to clear for provider", { provider }); return; } delete storageData.providers[provider]; storageData.lastModified = Date.now(); // If no more providers, delete the file entirely if (Object.keys(storageData.providers).length === 0) { await this.deleteStorageFile(); } else { await this.saveStorageData(storageData); } logger.info("OAuth tokens cleared successfully", { provider }); } catch (error) { if (error instanceof TokenStoreError && error.code === "NOT_FOUND") { logger.debug("No tokens to clear for provider", { provider }); return; } const errorMessage = error instanceof Error ? error.message : String(error); logger.error("Failed to clear OAuth tokens", { provider, error: errorMessage, }); throw new TokenStoreError(`Failed to clear tokens for ${provider}: ${errorMessage}`, "STORAGE_ERROR"); } }); } /** * Checks if the given tokens are expired * * @param tokens - The OAuth tokens to check * @param bufferMs - Buffer time in milliseconds before actual expiration (default: 5 minutes) * @returns true if token is expired or will expire within buffer time */ isTokenExpired(tokens, bufferMs = TokenStore.DEFAULT_EXPIRY_BUFFER_MS) { const now = Date.now(); return tokens.expiresAt - bufferMs <= now; } /** * Gets a valid access token for a provider, refreshing if needed * * If the stored token is expired or about to expire, and a refresher * function has been set, it will automatically refresh the token. * * @param provider - The provider name (e.g., "anthropic", "openai") * @returns The valid access token, or null if not available * @throws TokenStoreError if refresh fails */ async getValidToken(provider) { return withSpan({ name: "neurolink.auth.token.get_valid", tracer: tracers.auth, attributes: { "auth.provider": provider }, }, async (span) => this._getValidTokenImpl(provider, span)); } async _getValidTokenImpl(provider, span) { // Phase 1: Read token under mutex (fast) const snapshot = await this._mutex.runExclusive(async () => { const tokens = await this._loadTokensInternal(provider); if (!tokens) { return null; } return { ...tokens }; }); if (!snapshot) { span.setAttribute("auth.found", false); logger.debug("No tokens found for provider", { provider }); return null; } span.setAttribute("auth.found", true); // Token is still valid — return immediately if (!this.isTokenExpired(snapshot)) { span.setAttribute("auth.refreshed", false); span.setAttribute("auth.expired", false); return snapshot.accessToken; } span.setAttribute("auth.expired", true); logger.debug("Token expired or expiring soon", { provider, expiresAt: new Date(snapshot.expiresAt).toISOString(), }); // Phase 2: Refresh OUTSIDE the mutex so other reads are not blocked const refresher = this.tokenRefreshers.get(provider); if (!refresher || !snapshot.refreshToken) { logger.debug("No refresher configured or no refresh token available", { provider, hasRefresher: !!refresher, hasRefreshToken: !!snapshot.refreshToken, }); // No refresher/refresh-token available — fall back to hard expiry check. // The proactive buffer only applies when a refresh is possible. return this.isTokenExpired(snapshot, 0) ? null : snapshot.accessToken; } // Deduplicate concurrent refresh calls for the same provider: // If a refresh is already in-flight, await it instead of starting another. const existing = this.inFlightRefreshes.get(provider); if (existing) { logger.debug("Awaiting in-flight refresh for provider", { provider }); const result = await existing; return result.accessToken; } const refreshTokenValue = snapshot.refreshToken; const refreshPromise = (async () => { let newTokens; try { logger.info("Refreshing expired token", { provider }); newTokens = await refresher(refreshTokenValue); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); logger.error("Failed to refresh token", { provider, error: errorMessage, }); throw new TokenStoreError(`Failed to refresh token for ${provider}: ${errorMessage}`, "REFRESH_ERROR"); } // Phase 3: Write refreshed token under mutex (fast). // Re-check that the token is still expired before overwriting — // another caller may have already persisted a fresh token. // Also guard against resurrecting cleared/disabled entries: // if the entry was removed or disabled during the in-flight refresh, // skip the save to avoid re-creating a dead entry. const persistedTokens = await this._mutex.runExclusive(async () => { // Guard: do not resurrect cleared entries const current = await this._loadTokensInternal(provider); if (!current) { logger.debug("Skipping token persist — provider entry was cleared during refresh", { provider }); return newTokens; // return refreshed tokens to caller but don't persist } // Guard: do not resurrect disabled entries try { const storageData = await this.loadStorageData(); const providerData = storageData.providers[provider]; if (providerData?.disabled) { logger.debug("Skipping token persist — provider was disabled during refresh", { provider }); return newTokens; } } catch { // Storage read failure — proceed cautiously } if (!this.isTokenExpired(current)) { // Another caller already persisted a fresh token — use theirs. logger.debug("Skipping token persist — another refresh already wrote a valid token", { provider }); return current; } await this._saveTokensInternal(provider, newTokens); return newTokens; }); logger.info("Token refreshed successfully", { provider }); return persistedTokens; })(); this.inFlightRefreshes.set(provider, refreshPromise); try { const newTokens = await refreshPromise; span.setAttribute("auth.refreshed", true); return newTokens.accessToken; } finally { this.inFlightRefreshes.delete(provider); } } /** * Sets the token refresher function for a provider * * The refresher function will be called automatically when getValidToken * detects that the stored token is expired or about to expire. * * @param provider - The provider name (e.g., "anthropic", "openai") * @param refresher - Function that takes a refresh token and returns new tokens */ setTokenRefresher(provider, refresher) { this.tokenRefreshers.set(provider, refresher); logger.debug("Token refresher set for provider", { provider }); } /** * Removes the token refresher function for a provider * * @param provider - The provider name (e.g., "anthropic", "openai") */ clearTokenRefresher(provider) { this.tokenRefreshers.delete(provider); logger.debug("Token refresher cleared for provider", { provider }); } /** * Checks if tokens exist for a specific provider * * @param provider - The provider name (e.g., "anthropic", "openai") * @returns true if tokens are stored for the provider */ async hasTokens(provider) { try { const tokens = await this.loadTokens(provider); return tokens !== null; } catch { return false; } } /** * Lists all providers that have stored tokens * * @returns Array of provider names */ async listProviders() { try { const storageData = await this.loadStorageData(); return Object.keys(storageData.providers); } catch (error) { if (error instanceof TokenStoreError && error.code === "NOT_FOUND") { return []; } throw error; } } /** * Lists all provider keys that start with the given prefix * * @param prefix - The prefix to filter by (e.g., "anthropic:") * @returns Array of matching provider keys */ async listByPrefix(prefix) { const allProviders = await this.listProviders(); return allProviders.filter((key) => key.startsWith(prefix)); } // =========================================================================== // DISABLED STATE METHODS // =========================================================================== /** * Marks a provider's tokens as permanently disabled (persisted to disk). * * Disabled accounts are skipped immediately by consumers — no wasted * round-trips. The state survives proxy restarts because it is stored * alongside the tokens in the JSON file. * * @param provider - The provider key (e.g., "anthropic:user@example.com") * @param reason - Optional human-readable reason (e.g., "refresh_failed") */ async markDisabled(provider, reason) { return this._mutex.runExclusive(async () => { let storageData; try { storageData = await this.loadStorageData(); } catch (error) { if (error instanceof TokenStoreError && error.code === "NOT_FOUND") { logger.debug("No token storage found — nothing to disable", { provider, }); return; } throw error; } const providerData = storageData.providers[provider]; if (!providerData) { logger.debug("No tokens found for provider — nothing to disable", { provider, }); return; } providerData.disabled = true; providerData.disabledAt = Date.now(); providerData.disabledReason = reason; storageData.lastModified = Date.now(); await this.saveStorageData(storageData); logger.info("Provider marked as disabled", { provider, reason: reason ?? "unspecified", }); }); } /** * Re-enables a previously disabled provider (persisted to disk). * * Clears the disabled flag, timestamp, and reason so the account can * be used again by the proxy pool. * * @param provider - The provider key (e.g., "anthropic:user@example.com") */ async markEnabled(provider) { return this._mutex.runExclusive(async () => { let storageData; try { storageData = await this.loadStorageData(); } catch (error) { if (error instanceof TokenStoreError && error.code === "NOT_FOUND") { logger.debug("No token storage found — nothing to enable", { provider, }); return; } throw error; } const providerData = storageData.providers[provider]; if (!providerData) { logger.debug("No tokens found for provider — nothing to enable", { provider, }); return; } providerData.disabled = false; delete providerData.disabledAt; delete providerData.disabledReason; storageData.lastModified = Date.now(); await this.saveStorageData(storageData); logger.info("Provider re-enabled", { provider }); }); } /** * Checks whether a provider's tokens are currently disabled. * * Returns false for providers that don't exist in the store (no tokens * at all) and for entries that predate the disabled field (backward compat). * * @param provider - The provider key * @returns true if the provider entry exists and is disabled */ async isDisabled(provider) { return this._mutex.runExclusive(async () => { try { const storageData = await this.loadStorageData(); const providerData = storageData.providers[provider]; return providerData?.disabled === true; } catch (error) { if (error instanceof TokenStoreError && error.code === "NOT_FOUND") { return false; } throw error; } }); } /** * Lists all provider keys that are currently disabled. * * @returns Array of disabled provider keys (empty if none or no storage file) */ async listDisabled() { return this._mutex.runExclusive(async () => { try { const storageData = await this.loadStorageData(); return Object.entries(storageData.providers) .filter(([, data]) => data.disabled === true) .map(([key]) => key); } catch (error) { if (error instanceof TokenStoreError && error.code === "NOT_FOUND") { return []; } throw error; } }); } /** * Removes expired and unrefreshable token entries from disk. * * An entry is pruned when: * - Its access token is expired (with optional buffer) AND it has no * refresh token, AND it is NOT manually disabled. * * Manually disabled entries are preserved so that `auth enable` can * re-enable them later. They are only removed via explicit `clearTokens`. * * @param bufferMs - Extra buffer in milliseconds to subtract from expiresAt * when checking expiry (default: 0 — strict expiry check) * @returns Array of provider keys that were removed */ async pruneExpired(bufferMs = 0) { return this._mutex.runExclusive(async () => { let storageData; try { storageData = await this.loadStorageData(); } catch (error) { if (error instanceof TokenStoreError && error.code === "NOT_FOUND") { return []; } throw error; } const now = Date.now(); const pruned = []; for (const [key, providerData] of Object.entries(storageData.providers)) { const isExpired = providerData.tokens.expiresAt - bufferMs <= now; const hasNoRefreshToken = !providerData.tokens.refreshToken; const isManuallyDisabled = providerData.disabled === true; // Only prune expired+unrefreshable entries that are NOT manually disabled if (isExpired && hasNoRefreshToken && !isManuallyDisabled) { delete storageData.providers[key]; pruned.push(key); } } if (pruned.length > 0) { storageData.lastModified = Date.now(); if (Object.keys(storageData.providers).length === 0) { await this.deleteStorageFile(); } else { await this.saveStorageData(storageData); } logger.info("Pruned expired token entries", { removed: pruned, count: pruned.length, }); } return pruned; }); } /** * Clears all stored tokens for all providers * * @throws TokenStoreError if deletion fails */ async clearAllTokens() { return this._mutex.runExclusive(async () => { // Clear all in-memory refresh state so re-adding accounts starts fresh this.inFlightRefreshes.clear(); this.tokenRefreshers.clear(); await this.deleteStorageFile(); logger.info("All OAuth tokens cleared"); }); } // =========================================================================== // PRIVATE HELPER METHODS // =========================================================================== /** * Loads the storage data from file */ async loadStorageData() { try { await access(this.storagePath); } catch { // File doesn't exist, return empty storage throw new TokenStoreError("Token storage file not found", "NOT_FOUND"); } try { const content = await readFile(this.storagePath, "utf-8"); let storageData; if (this.encryptionEnabled) { const decrypted = this.deobfuscate(content); storageData = JSON.parse(decrypted); } else { storageData = JSON.parse(content); } // Validate storage data structure if (!storageData.providers || !storageData.version) { throw new TokenStoreError("Invalid token storage format", "VALIDATION_ERROR"); } return storageData; } catch (error) { if (error instanceof TokenStoreError) { throw error; } const errorMessage = error instanceof Error ? error.message : String(error); logger.error("Failed to read token storage", { error: errorMessage }); throw new TokenStoreError(`Failed to read token storage: ${errorMessage}`, "STORAGE_ERROR"); } } /** * Saves storage data to file */ async saveStorageData(data) { try { const content = this.encryptionEnabled ? this.obfuscate(JSON.stringify(data)) : JSON.stringify(data, null, 2); // Use PID-scoped temp file to avoid cross-process race conditions const tmpPath = `${this.storagePath}.tmp.${process.pid}`; await writeFile(tmpPath, content, "utf-8"); await chmod(tmpPath, TokenStore.FILE_PERMISSIONS); await rename(tmpPath, this.storagePath); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); throw new TokenStoreError(`Failed to save token storage: ${errorMessage}`, "STORAGE_ERROR"); } } /** * Deletes the storage file */ async deleteStorageFile() { try { await access(this.storagePath); await unlink(this.storagePath); } catch (error) { if (error.code === "ENOENT") { // File doesn't exist, nothing to delete return; } throw error; } } /** * Validates token structure */ validateTokens(tokens) { if (!tokens.accessToken || typeof tokens.accessToken !== "string") { throw new TokenStoreError("Invalid access token: must be a non-empty string", "VALIDATION_ERROR"); } if (tokens.refreshToken !== undefined && typeof tokens.refreshToken !== "string") { throw new TokenStoreError("Invalid refresh token: must be a string when provided", "VALIDATION_ERROR"); } if (typeof tokens.expiresAt !== "number" || tokens.expiresAt <= 0) { throw new TokenStoreError("Invalid expiresAt: must be a positive number", "VALIDATION_ERROR"); } if (!tokens.tokenType || typeof tokens.tokenType !== "string") { throw new TokenStoreError("Invalid token type: must be a non-empty string", "VALIDATION_ERROR"); } } /** * Ensures the storage directory exists with proper permissions */ async ensureDirectory(dirPath) { try { await mkdir(dirPath, { recursive: true, mode: TokenStore.DIR_PERMISSIONS, }); } catch { // Directory might already exist, try to set permissions try { await chmod(dirPath, TokenStore.DIR_PERMISSIONS); } catch { // Ignore permission errors on existing directories } } } /** * Derives an encoding key from machine-specific information * This provides basic obfuscation - for production use, consider * using proper encryption with a user-provided key or system keychain */ deriveEncryptionKey() { const machineInfo = `${homedir()}-neurolink-token-store`; return createHash("sha256").update(machineInfo).digest("hex"); } /** * Simple XOR-based obfuscation * Note: This is NOT cryptographically secure, just basic obfuscation * For production use, consider using node:crypto with proper encryption */ obfuscate(data) { const key = this.encryptionKey; let result = ""; for (let i = 0; i < data.length; i++) { const charCode = data.charCodeAt(i) ^ key.charCodeAt(i % key.length); result += String.fromCharCode(charCode); } // Encode as base64 for safe storage return Buffer.from(result, "binary").toString("base64"); } /** * Reverses the XOR obfuscation */ deobfuscate(data) { const key = this.encryptionKey; // Decode from base64 const decoded = Buffer.from(data, "base64").toString("binary"); let result = ""; for (let i = 0; i < decoded.length; i++) { const charCode = decoded.charCodeAt(i) ^ key.charCodeAt(i % key.length); result += String.fromCharCode(charCode); } return result; } } // ============================================================================= // SINGLETON INSTANCE // ============================================================================= /** * Default token store singleton instance * Uses default configuration with encryption enabled */ export const tokenStore = new TokenStore(); /** * Alias for backward compatibility * @deprecated Use tokenStore instead */ export const defaultTokenStore = tokenStore;