/** * Session-based credentials management * * IMPORTANT: Each session is completely isolated. * Credentials are stored ONLY by sessionId, never shared between sessions. * This prevents credential leakage when multiple Claude tabs connect to different websites. * * AUTH TOKEN PERSISTENCE: * Claude MCP frequently reconnects SSE, creating new sessionIds each time. * To maintain credentials across reconnections, we also index by authToken * (the SimpleAuth header that Claude sends with each request). */ import fs from "node:fs"; import path from "node:path"; import { fetchProjectInfo } from "./localClient.js"; const DEFAULT_ROLE = 'developer'; const FORGE_INTERNAL_URL = process.env.ACAI_FORGE_WEB_URL || "http://web:80"; const resolveEffectiveRole = (baseRole) => { if (process.env.ACAI_ROLE_OVERRIDE) return process.env.ACAI_ROLE_OVERRIDE; if (process.env.ACAI_MODE_OVERRIDE === "production") return "editor"; if (process.env.ACAI_MODE === "production") return "editor"; return baseRole || DEFAULT_ROLE; }; // Session-based credentials storage (ephemeral, per-session) export const sessionCredentials = new Map(); export const sessionApiClients = new Map(); export const sessionUserTokens = new Map(); // Map sessionId -> McpServer instance (for role-based tool filtering) export const sessionServers = new Map(); // MCP-Session-Id -> credentials mapping for persistence across SSE reconnections // This is the standard MCP mechanism for session persistence // Key: MCP-Session-Id (UUID), Value: { credentials, lastAccess } export const mcpSessionCredentials = new Map(); // TTL for MCP session credentials (30 minutes - longer than authToken since it's per-conversation) const MCP_SESSION_TTL_MS = 30 * 60 * 1000; /** * Clean up expired MCP session credentials */ const cleanupExpiredMcpSessions = () => { const now = Date.now(); for (const [mcpSessionId, data] of mcpSessionCredentials.entries()) { if (now - data.lastAccess > MCP_SESSION_TTL_MS) { console.error(`[Credentials] Cleaning up expired MCP-Session-Id ${mcpSessionId.substring(0, 8)}... (age: ${Math.round((now - data.lastAccess) / 1000)}s)`); mcpSessionCredentials.delete(mcpSessionId); } } }; // Run cleanup every 5 minutes setInterval(cleanupExpiredMcpSessions, 5 * 60 * 1000); const buildApiUrlFromPublicUrl = (webUrl, explicitForgeHost = "") => { if (!webUrl) return null; if (explicitForgeHost) return FORGE_INTERNAL_URL; try { const parsed = new URL(webUrl); if (parsed.hostname.includes(".forge.")) { return FORGE_INTERNAL_URL; } } catch { return null; } return webUrl; }; const readProjectAcaiFallback = () => { const projectDir = process.env.ACAI_PROJECT_DIR || ""; if (!projectDir) return null; const acaiFilePath = path.join(projectDir, ".acai"); if (!fs.existsSync(acaiFilePath)) return null; try { const data = JSON.parse(fs.readFileSync(acaiFilePath, "utf-8")); const website = data.domain || process.env.ACAI_WEBSITE || null; let webUrl = data.local_web_url || process.env.ACAI_WEB_URL || (website ? `https://${website}` : null); let forgeHost = data.local_forge_host || process.env.ACAI_FORGE_HOST || null; // Respeta local_api_web_url del .acai si esta presente (override del usuario); // si no, fallback al calculo automatico (web:80 para forge, web_url para Docker). let apiWebUrl = data.local_api_web_url || process.env.ACAI_API_WEB_URL || buildApiUrlFromPublicUrl(webUrl, forgeHost); let mode = data.mode || process.env.ACAI_MODE || "local"; // Override de entorno (inyectado por cronjobs via mcp_env) if (process.env.ACAI_MODE_OVERRIDE) { mode = process.env.ACAI_MODE_OVERRIDE; if (process.env.ACAI_WEB_URL_OVERRIDE) webUrl = process.env.ACAI_WEB_URL_OVERRIDE; if (process.env.ACAI_API_WEB_URL_OVERRIDE) apiWebUrl = process.env.ACAI_API_WEB_URL_OVERRIDE; if (process.env.ACAI_FORGE_HOST_OVERRIDE !== undefined) forgeHost = process.env.ACAI_FORGE_HOST_OVERRIDE; } return { token: data.token || process.env.ACAI_TOKEN || null, website, web_url: webUrl, api_web_url: apiWebUrl, forge_host: forgeHost, tokenHash: data.tokenHash || process.env.ACAI_TOKEN_HASH || null, mode, profileName: "acai-file", role: resolveEffectiveRole(data.role), }; } catch (error) { console.error(`[Credentials] Failed to read .acai fallback: ${error.message}`); return null; } }; const resolveLocalProjectFallback = async () => { const projectDir = process.env.ACAI_PROJECT_DIR || ""; if (projectDir) { try { const info = await fetchProjectInfo({ project_dir: projectDir }); if (info?.success) { let webUrl = info.web_url || process.env.ACAI_WEB_URL || null; let apiWebUrl = info.api_web_url || buildApiUrlFromPublicUrl(info.web_url, info.forge_host) || null; let forgeHost = info.forge_host || process.env.ACAI_FORGE_HOST || null; let mode = info.mode || process.env.ACAI_MODE || "local"; // Override de entorno (inyectado por cronjobs via mcp_env) if (process.env.ACAI_MODE_OVERRIDE) { mode = process.env.ACAI_MODE_OVERRIDE; if (process.env.ACAI_WEB_URL_OVERRIDE) webUrl = process.env.ACAI_WEB_URL_OVERRIDE; if (process.env.ACAI_API_WEB_URL_OVERRIDE) apiWebUrl = process.env.ACAI_API_WEB_URL_OVERRIDE; if (process.env.ACAI_FORGE_HOST_OVERRIDE !== undefined) forgeHost = process.env.ACAI_FORGE_HOST_OVERRIDE; } return { token: info.token || process.env.ACAI_TOKEN || null, website: info.domain || process.env.ACAI_WEBSITE || null, web_url: webUrl, api_web_url: apiWebUrl, forge_host: forgeHost, tokenHash: info.tokenHash || process.env.ACAI_TOKEN_HASH || null, mode, profileName: "project-info", role: resolveEffectiveRole(info.role), }; } } catch (error) { console.error(`[Credentials] project-info fallback failed: ${error.message}`); } } return readProjectAcaiFallback(); }; /** * Get credentials by MCP-Session-Id */ export const getMcpSessionCredentials = (mcpSessionId) => { const data = mcpSessionCredentials.get(mcpSessionId); if (data) { data.lastAccess = Date.now(); console.error(`[Credentials] getMcpSessionCredentials(${mcpSessionId.substring(0, 8)}...) - FOUND: website=${data.credentials.website}`); return data.credentials; } console.error(`[Credentials] getMcpSessionCredentials(${mcpSessionId.substring(0, 8)}...) - NOT FOUND`); return null; }; /** * Set credentials by MCP-Session-Id */ export const setMcpSessionCredentials = (mcpSessionId, credentials) => { mcpSessionCredentials.set(mcpSessionId, { credentials, lastAccess: Date.now() }); console.error(`[Credentials] setMcpSessionCredentials(${mcpSessionId.substring(0, 8)}...) - website=${credentials.website} (total: ${mcpSessionCredentials.size})`); }; /** * Find role by token lookup in session storage * @param {string} token - The acaiToken to lookup * @returns {string|null} The role associated with this token, or null if not found */ export const findRoleByToken = (token) => { if (!token) return null; // Search in sessionCredentials Map for (const [sessionId, creds] of sessionCredentials.entries()) { if (creds.token === token && creds.role) { console.error(`[Credentials] findRoleByToken - FOUND role=${creds.role} for token in session ${sessionId}`); return creds.role; } } // Search in mcpSessionCredentials Map for (const [mcpSessionId, data] of mcpSessionCredentials.entries()) { if (data.credentials.token === token && data.credentials.role) { console.error(`[Credentials] findRoleByToken - FOUND role=${data.credentials.role} for token in MCP session ${mcpSessionId.substring(0, 8)}...`); return data.credentials.role; } } console.error(`[Credentials] findRoleByToken - NOT FOUND for token`); return null; }; /** * Get credentials for a specific session * Supports inline credentials that take priority over session credentials. * This allows Claude to send credentials with each request (stateless mode). * * @param {string} sessionId - The session ID * @param {Object} inlineCredentials - Optional inline credentials from tool params * @param {string} inlineCredentials.acaiToken - Token passed directly in tool call * @param {string} inlineCredentials.acaiWebsite - Website passed directly in tool call * @param {string} inlineCredentials.acaiTokenHash - Token hash passed directly in tool call */ export const getSessionCredentials = async (sessionId, inlineCredentials = null) => { // Priority 1: Inline credentials (stateless mode - Claude sends token with each request) if (inlineCredentials?.acaiToken && inlineCredentials?.acaiWebsite) { // Lookup role by token in session storage const role = findRoleByToken(inlineCredentials.acaiToken); console.error(`[Credentials] getSessionCredentials(${sessionId}) - USING INLINE: website=${inlineCredentials.acaiWebsite}, role=${role || 'not found'}`); return { token: inlineCredentials.acaiToken, website: inlineCredentials.acaiWebsite, web_url: `https://${inlineCredentials.acaiWebsite}`, api_web_url: null, forge_host: null, tokenHash: inlineCredentials.acaiTokenHash || null, profileName: 'inline', role: role || DEFAULT_ROLE // Merge role from session storage, fallback to default }; } // Priority 2: Session credentials const sessionCreds = sessionCredentials.get(sessionId); if (sessionCreds) { if (sessionCreds.token && sessionCreds.web_url && sessionCreds.api_web_url) { console.error(`[Credentials] getSessionCredentials(${sessionId}) - FOUND: website=${sessionCreds.website}, hasToken=${!!sessionCreds.token}`); return sessionCreds; } const hydrated = await resolveLocalProjectFallback(); if (hydrated) { const merged = { ...sessionCreds, token: sessionCreds.token || hydrated.token, website: sessionCreds.website || hydrated.website, web_url: sessionCreds.web_url || hydrated.web_url, api_web_url: sessionCreds.api_web_url || hydrated.api_web_url, forge_host: sessionCreds.forge_host || hydrated.forge_host, tokenHash: sessionCreds.tokenHash || hydrated.tokenHash, profileName: sessionCreds.profileName || hydrated.profileName, role: sessionCreds.role || hydrated.role, }; sessionCredentials.set(sessionId, merged); console.error(`[Credentials] getSessionCredentials(${sessionId}) - HYDRATED from local fallback`); return merged; } console.error(`[Credentials] getSessionCredentials(${sessionId}) - FOUND: website=${sessionCreds.website}, hasToken=${!!sessionCreds.token}`); return sessionCreds; } const localFallback = await resolveLocalProjectFallback(); if (localFallback) { sessionCredentials.set(sessionId, localFallback); console.error(`[Credentials] getSessionCredentials(${sessionId}) - USING local project fallback`); return localFallback; } // Priority 3: Fallback to environment variables (for backwards compatibility) console.error(`[Credentials] getSessionCredentials(${sessionId}) - NOT FOUND, using env fallback`); console.error(`[Credentials] Active sessions: [${Array.from(sessionCredentials.keys()).join(', ')}]`); console.error(`[Credentials] Active MCP sessions: ${mcpSessionCredentials.size}`); const envWebsite = process.env.ACAI_WEBSITE || null; return { token: process.env.ACAI_TOKEN || null, website: envWebsite, web_url: process.env.ACAI_WEB_URL || (envWebsite ? `https://${envWebsite}` : null), api_web_url: process.env.ACAI_API_WEB_URL || null, forge_host: process.env.ACAI_FORGE_HOST || null, tokenHash: process.env.ACAI_TOKEN_HASH || null, profileName: 'default', role: resolveEffectiveRole('developer'), // Env fallback = local dev, full access }; }; /** * Get X-User-Token for a specific session (for fallback login) */ export const getSessionUserToken = (sessionId) => { return sessionUserTokens.get(sessionId) || null; }; /** * Set X-User-Token for a specific session */ export const setSessionUserToken = (userToken, sessionId) => { if (userToken) { sessionUserTokens.set(sessionId, userToken); } }; /** * Set credentials for a specific session * Credentials are stored by sessionId AND by mcpSessionId (for SSE reconnection persistence). * @param {Object} credentials - The credentials object * @param {string} sessionId - The session ID (SSE transport session) * @param {string} mcpSessionId - Optional MCP-Session-Id for persistence across SSE reconnections */ export const setCredentials = async ({ website, web_url, api_web_url, forge_host, token, tokenHash, profileName, role }, sessionId, mcpSessionId = null) => { console.error(`[Credentials] setCredentials(${sessionId}) - website=${website}, web_url=${web_url}, api_web_url=${api_web_url}, forge_host=${forge_host || ""}, hasToken=${!!token}, hasTokenHash=${!!tokenHash}, profile=${profileName || "manual"}, role=${role || 'default'}, hasMcpSessionId=${!!mcpSessionId}`); const creds = { website, web_url: web_url || (website ? `https://${website}` : null), api_web_url: api_web_url || null, forge_host: forge_host || null, token, tokenHash, profileName: profileName || "manual", role: role || DEFAULT_ROLE, }; // Store by sessionId sessionCredentials.set(sessionId, creds); // Also store by MCP-Session-Id for persistence across SSE reconnections if (mcpSessionId) { setMcpSessionCredentials(mcpSessionId, creds); } // Verify it was set const verify = sessionCredentials.get(sessionId); console.error(`[Credentials] Verification: exists=${!!verify}, website=${verify?.website}`); }; /** * Clear credentials for a session * NOTE: We only clear the sessionId mappings, NOT the authToken mappings. * This allows credentials to persist across SSE reconnections. * AuthToken credentials will be cleaned up by the TTL cleanup routine. */ export const clearSessionCredentials = (sessionId) => { console.error(`[Credentials] Clearing session ${sessionId} (authToken credentials preserved for reconnection)`); sessionCredentials.delete(sessionId); sessionApiClients.delete(sessionId); sessionUserTokens.delete(sessionId); sessionServers.delete(sessionId); // NOTE: We intentionally do NOT clear authTokenCredentials here // to allow credentials to persist across SSE reconnections }; /** * Get common params for API requests * @param {string} sessionId - The session ID * @param {Object} extraParams - Extra parameters to include */ export const getCommonParams = async (sessionId, extraParams = {}) => { const creds = await getSessionCredentials(sessionId); const params = { token: creds.token, ...extraParams }; if (creds.tokenHash) { params.tokenHash = creds.tokenHash; } return params; }; /** * Extract MCP-Session-Id from request headers * @param {Object} extra - The extra object passed to tool handlers * @returns {string|null} The MCP-Session-Id or null */ export const extractMcpSessionId = (extra) => { const headers = extra?.requestInfo?.headers; if (!headers) return null; return headers['mcp-session-id'] || null; };