381 lines
16 KiB
JavaScript
381 lines
16 KiB
JavaScript
/**
|
|
* 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";
|
|
|
|
|
|
// 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: DEFAULT_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: DEFAULT_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: '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;
|
|
};
|