diff --git a/api/app/clients/tools/util/handleTools.js b/api/app/clients/tools/util/handleTools.js index b988d03500..89a79f3cbd 100644 --- a/api/app/clients/tools/util/handleTools.js +++ b/api/app/clients/tools/util/handleTools.js @@ -6,6 +6,7 @@ const { createSafeUser, mcpToolPattern, loadWebSearchAuth, + getCodeApiAuthHeaders, buildImageToolContext, buildWebSearchContext, buildWebSearchDynamicContext, @@ -282,7 +283,11 @@ const loadTools = async ({ if (files?.length) { primedCodeFiles = files; } - return createCodeExecutionTool({ user_id: user, files }); + return createCodeExecutionTool({ + user_id: user, + files, + authHeaders: () => getCodeApiAuthHeaders(options.req), + }); }; continue; } else if (tool === Tools.file_search) { diff --git a/api/package.json b/api/package.json index 3c864d1e20..6cbca655b9 100644 --- a/api/package.json +++ b/api/package.json @@ -45,7 +45,7 @@ "@azure/storage-blob": "^12.30.0", "@google/genai": "^1.19.0", "@keyv/redis": "^4.3.3", - "@librechat/agents": "^3.1.83", + "@librechat/agents": "^3.1.84", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", diff --git a/api/server/middleware/__tests__/requireJwtAuth.spec.js b/api/server/middleware/__tests__/requireJwtAuth.spec.js index bc288e5dab..a884cb5b3f 100644 --- a/api/server/middleware/__tests__/requireJwtAuth.spec.js +++ b/api/server/middleware/__tests__/requireJwtAuth.spec.js @@ -13,17 +13,25 @@ const { getTenantId } = require('@librechat/data-schemas'); // ── Mocks ────────────────────────────────────────────────────────────── let mockPassportError = null; +let mockRegisteredStrategies = new Set(['jwt']); jest.mock('passport', () => ({ - authenticate: jest.fn(() => { - return (req, _res, done) => { + _strategy: jest.fn((strategy) => (mockRegisteredStrategies.has(strategy) ? {} : undefined)), + authenticate: jest.fn((strategy, _options, callback) => { + return (req, _res, _done) => { if (mockPassportError) { - return done(mockPassportError); + return callback(mockPassportError); } - if (req._mockUser) { - req.user = req._mockUser; + const strategyResult = req._mockStrategies?.[strategy]; + if (strategyResult) { + return callback( + strategyResult.err ?? null, + strategyResult.user ?? false, + strategyResult.info, + strategyResult.status, + ); } - done(); + return callback(null, req._mockUser ?? false, { message: 'Unauthorized' }, 401); }; }), })); @@ -49,9 +57,11 @@ jest.mock('@librechat/api', () => { // ── Helpers ───────────────────────────────────────────────────────────── const requireJwtAuth = require('../requireJwtAuth'); +const { isEnabled } = require('@librechat/api'); +const passport = require('passport'); -function mockReq(user) { - return { headers: {}, _mockUser: user }; +function mockReq(user, extra = {}) { + return { headers: {}, _mockUser: user, ...extra }; } function mockRes() { @@ -74,6 +84,10 @@ function runAuth(user) { describe('requireJwtAuth tenant context chaining', () => { afterEach(() => { mockPassportError = null; + mockRegisteredStrategies = new Set(['jwt']); + isEnabled.mockReturnValue(false); + passport.authenticate.mockClear(); + passport._strategy.mockClear(); }); it('forwards passport errors to next() without entering tenant middleware', async () => { @@ -98,9 +112,61 @@ describe('requireJwtAuth tenant context chaining', () => { expect(tenantId).toBeUndefined(); }); - it('ALS tenant context is NOT set when user is undefined', async () => { - const tenantId = await runAuth(undefined); - expect(tenantId).toBeUndefined(); + it('returns 401 when no strategy authenticates a user', async () => { + const req = mockReq(undefined); + const res = mockRes(); + const next = jest.fn(); + + requireJwtAuth(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(401); + expect(getTenantId()).toBeUndefined(); + }); + + it('falls back to OpenID JWT for bearer-only reuse requests', async () => { + isEnabled.mockReturnValue(true); + mockRegisteredStrategies.add('openidJwt'); + const req = mockReq(undefined, { + _mockStrategies: { + jwt: { user: false, info: { message: 'invalid signature' }, status: 401 }, + openidJwt: { user: { tenantId: 'tenant-openid', role: 'user' } }, + }, + }); + const res = mockRes(); + const tenantId = await new Promise((resolve) => { + requireJwtAuth(req, res, () => { + resolve(getTenantId()); + }); + }); + + expect(tenantId).toBe('tenant-openid'); + expect(req.authStrategy).toBe('openidJwt'); + expect(res.status).not.toHaveBeenCalled(); + }); + + it('skips OpenID JWT fallback when the strategy was not registered', async () => { + isEnabled.mockReturnValue(true); + const req = mockReq(undefined, { + _mockStrategies: { + jwt: { user: false, info: { message: 'invalid signature' }, status: 401 }, + openidJwt: { user: { tenantId: 'tenant-openid', role: 'user' } }, + }, + }); + const res = mockRes(); + const next = jest.fn(); + + requireJwtAuth(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(401); + expect(req.authStrategy).toBeUndefined(); + expect(passport.authenticate).toHaveBeenCalledTimes(1); + expect(passport.authenticate).toHaveBeenCalledWith( + 'jwt', + { session: false }, + expect.any(Function), + ); }); it('concurrent requests get isolated tenant contexts', async () => { diff --git a/api/server/middleware/optionalJwtAuth.js b/api/server/middleware/optionalJwtAuth.js index d46478d36e..853579415e 100644 --- a/api/server/middleware/optionalJwtAuth.js +++ b/api/server/middleware/optionalJwtAuth.js @@ -2,23 +2,31 @@ const cookies = require('cookie'); const passport = require('passport'); const { isEnabled, tenantContextMiddleware } = require('@librechat/api'); +const hasPassportStrategy = (strategy) => + typeof passport._strategy === 'function' && passport._strategy(strategy) != null; + // This middleware does not require authentication, // but if the user is authenticated, it will set the user object // and establish tenant ALS context. const optionalJwtAuth = (req, res, next) => { const cookieHeader = req.headers.cookie; const tokenProvider = cookieHeader ? cookies.parse(cookieHeader).token_provider : null; + const useOpenIdJwt = + tokenProvider === 'openid' && + isEnabled(process.env.OPENID_REUSE_TOKENS) && + hasPassportStrategy('openidJwt'); const callback = (err, user) => { if (err) { return next(err); } if (user) { req.user = user; + req.authStrategy = useOpenIdJwt ? 'openidJwt' : 'jwt'; return tenantContextMiddleware(req, res, next); } next(); }; - if (tokenProvider === 'openid' && isEnabled(process.env.OPENID_REUSE_TOKENS)) { + if (useOpenIdJwt) { return passport.authenticate('openidJwt', { session: false }, callback)(req, res, next); } passport.authenticate('jwt', { session: false }, callback)(req, res, next); diff --git a/api/server/middleware/requireJwtAuth.js b/api/server/middleware/requireJwtAuth.js index b13e991b23..5a9e21b6d3 100644 --- a/api/server/middleware/requireJwtAuth.js +++ b/api/server/middleware/requireJwtAuth.js @@ -2,6 +2,9 @@ const cookies = require('cookie'); const passport = require('passport'); const { isEnabled, tenantContextMiddleware } = require('@librechat/api'); +const hasPassportStrategy = (strategy) => + typeof passport._strategy === 'function' && passport._strategy(strategy) != null; + /** * Custom Middleware to handle JWT authentication, with support for OpenID token reuse. * Switches between JWT and OpenID authentication based on cookies and environment settings. @@ -13,17 +16,35 @@ const { isEnabled, tenantContextMiddleware } = require('@librechat/api'); const requireJwtAuth = (req, res, next) => { const cookieHeader = req.headers.cookie; const tokenProvider = cookieHeader ? cookies.parse(cookieHeader).token_provider : null; + const openidReuseEnabled = isEnabled(process.env.OPENID_REUSE_TOKENS); + const openidJwtAvailable = openidReuseEnabled && hasPassportStrategy('openidJwt'); + const strategies = + tokenProvider === 'openid' && openidJwtAvailable + ? ['openidJwt', 'jwt'] + : ['jwt', ...(openidJwtAvailable ? ['openidJwt'] : [])]; - const strategy = - tokenProvider === 'openid' && isEnabled(process.env.OPENID_REUSE_TOKENS) ? 'openidJwt' : 'jwt'; + const authenticateWithStrategy = (index) => { + const strategy = strategies[index]; + passport.authenticate(strategy, { session: false }, (err, user, info, status) => { + if (err) { + return next(err); + } + if (!user) { + if (index + 1 < strategies.length) { + return authenticateWithStrategy(index + 1); + } + return res.status(status || 401).json({ + message: info?.message || 'Unauthorized', + }); + } + req.user = user; + req.authStrategy = strategy; + // req.user is now populated by passport — set up tenant ALS context + tenantContextMiddleware(req, res, next); + })(req, res, next); + }; - passport.authenticate(strategy, { session: false })(req, res, (err) => { - if (err) { - return next(err); - } - // req.user is now populated by passport — set up tenant ALS context - tenantContextMiddleware(req, res, next); - }); + authenticateWithStrategy(0); }; module.exports = requireJwtAuth; diff --git a/api/server/routes/files/files.js b/api/server/routes/files/files.js index a43c184e50..b660154975 100644 --- a/api/server/routes/files/files.js +++ b/api/server/routes/files/files.js @@ -318,10 +318,14 @@ router.get('/code/download/:session_id/:fileId', async (req, res) => { * sessionKey; without these query params it 400s with * "kind must be one of: skill, agent, user". */ /** @type {AxiosResponse | undefined} */ - const response = await getDownloadStream(`${session_id}/${fileId}`, { - kind: 'user', - id: req.user.id, - }); + const response = await getDownloadStream( + `${session_id}/${fileId}`, + { + kind: 'user', + id: req.user.id, + }, + req, + ); res.set(response.headers); response.data.pipe(res); } catch (error) { diff --git a/api/server/services/Files/Code/__tests__/process-traversal.spec.js b/api/server/services/Files/Code/__tests__/process-traversal.spec.js index 751db31bc2..791d6d258c 100644 --- a/api/server/services/Files/Code/__tests__/process-traversal.spec.js +++ b/api/server/services/Files/Code/__tests__/process-traversal.spec.js @@ -25,6 +25,7 @@ jest.mock('@librechat/api', () => { sanitizeArtifactPath: mockSanitizeArtifactPath, flattenArtifactPath: mockFlattenArtifactPath, createAxiosInstance: jest.fn(() => mockAxios), + getCodeApiAuthHeaders: jest.fn(async () => ({})), classifyCodeArtifact: jest.fn(() => 'other'), extractCodeArtifactText: jest.fn(async () => null), /* `processCodeOutput` calls this to derive the trust flag persisted diff --git a/api/server/services/Files/Code/crud.js b/api/server/services/Files/Code/crud.js index cb25f351ec..eec205bb07 100644 --- a/api/server/services/Files/Code/crud.js +++ b/api/server/services/Files/Code/crud.js @@ -9,6 +9,7 @@ const { codeServerHttpsAgent, appendCodeEnvFileIdentity, buildCodeEnvDownloadQuery, + getCodeApiAuthHeaders, } = require('@librechat/api'); const axios = createAxiosInstance(); @@ -26,10 +27,11 @@ const MAX_FILE_SIZE = 150 * 1024 * 1024; * @returns {Promise} A promise that resolves to a readable stream of the file content. * @throws {Error} If there's an error during the download process. */ -async function getCodeOutputDownloadStream(fileIdentifier, identity) { +async function getCodeOutputDownloadStream(fileIdentifier, identity, req) { try { const baseURL = getCodeBaseURL(); const query = buildCodeEnvDownloadQuery(identity); + const authHeaders = await getCodeApiAuthHeaders(req); /** @type {import('axios').AxiosRequestConfig} */ const options = { method: 'get', @@ -37,6 +39,7 @@ async function getCodeOutputDownloadStream(fileIdentifier, identity) { responseType: 'stream', headers: { 'User-Agent': 'LibreChat/1.0', + ...authHeaders, }, httpAgent: codeServerHttpAgent, httpsAgent: codeServerHttpsAgent, @@ -85,6 +88,7 @@ async function uploadCodeEnvFile({ req, stream, filename, kind, id, version }) { appendCodeEnvFile(form, stream, filename); const baseURL = getCodeBaseURL(); + const authHeaders = await getCodeApiAuthHeaders(req); /** @type {import('axios').AxiosRequestConfig} */ const options = { headers: { @@ -92,6 +96,7 @@ async function uploadCodeEnvFile({ req, stream, filename, kind, id, version }) { 'Content-Type': 'multipart/form-data', 'User-Agent': 'LibreChat/1.0', 'User-Id': req.user.id, + ...authHeaders, }, httpAgent: codeServerHttpAgent, httpsAgent: codeServerHttpsAgent, @@ -156,6 +161,7 @@ async function batchUploadCodeEnvFiles({ req, files, kind, id, version, read_onl } const baseURL = getCodeBaseURL(); + const authHeaders = await getCodeApiAuthHeaders(req); /** @type {import('axios').AxiosRequestConfig} */ const options = { headers: { @@ -163,6 +169,7 @@ async function batchUploadCodeEnvFiles({ req, files, kind, id, version, read_onl 'Content-Type': 'multipart/form-data', 'User-Agent': 'LibreChat/1.0', 'User-Id': req.user.id, + ...authHeaders, }, httpAgent: codeServerHttpAgent, httpsAgent: codeServerHttpsAgent, diff --git a/api/server/services/Files/Code/crud.spec.js b/api/server/services/Files/Code/crud.spec.js index a84689d8bf..e26eb4dbbb 100644 --- a/api/server/services/Files/Code/crud.spec.js +++ b/api/server/services/Files/Code/crud.spec.js @@ -49,18 +49,24 @@ jest.mock('@librechat/api', () => { return `?${params.toString()}`; }), logAxiosError: jest.fn(({ message }) => message), + getCodeApiAuthHeaders: jest.fn(async () => ({})), createAxiosInstance: jest.fn(() => mockAxios), codeServerHttpAgent: new http.Agent({ keepAlive: false }), codeServerHttpsAgent: new https.Agent({ keepAlive: false }), }; }); -const { codeServerHttpAgent, codeServerHttpsAgent } = require('@librechat/api'); +const { + codeServerHttpAgent, + codeServerHttpsAgent, + getCodeApiAuthHeaders, +} = require('@librechat/api'); const { getCodeOutputDownloadStream, uploadCodeEnvFile } = require('./crud'); describe('Code CRUD', () => { beforeEach(() => { jest.clearAllMocks(); + getCodeApiAuthHeaders.mockResolvedValue({}); }); describe('getCodeOutputDownloadStream', () => { @@ -101,6 +107,18 @@ describe('Code CRUD', () => { expect(callConfig.timeout).toBe(15000); }); + it('forwards Code API auth headers when a request is provided', async () => { + const req = { user: { id: 'user-123' } }; + getCodeApiAuthHeaders.mockResolvedValue({ Authorization: 'Bearer codeapi-token' }); + mockAxios.mockResolvedValue({ data: Readable.from(['chunk']) }); + + await getCodeOutputDownloadStream('session-1/file-1', userIdentity, req); + + const callConfig = mockAxios.mock.calls[0][0]; + expect(getCodeApiAuthHeaders).toHaveBeenCalledWith(req); + expect(callConfig.headers.Authorization).toBe('Bearer codeapi-token'); + }); + it('forwards skill identity (kind/id/version) when re-downloading a primed skill file', async () => { mockAxios.mockResolvedValue({ data: Readable.from(['chunk']) }); @@ -194,6 +212,23 @@ describe('Code CRUD', () => { expect(result).toEqual({ storage_session_id: 'sess-1', file_id: 'fid-1' }); }); + it('forwards Code API auth headers on upload requests', async () => { + getCodeApiAuthHeaders.mockResolvedValue({ Authorization: 'Bearer codeapi-token' }); + mockAxios.post.mockResolvedValue({ + data: { + message: 'success', + storage_session_id: 'sess-1', + files: [{ fileId: 'fid-1', filename: 'data.csv' }], + }, + }); + + await uploadCodeEnvFile(baseUploadParams); + + const callConfig = mockAxios.post.mock.calls[0][2]; + expect(getCodeApiAuthHeaders).toHaveBeenCalledWith(baseUploadParams.req); + expect(callConfig.headers.Authorization).toBe('Bearer codeapi-token'); + }); + /* Phase C / option α (codeapi #1455): the upload wire carries the * resource identity codeapi uses for sessionKey derivation. Without * these on the form, codeapi falls back to user bucketing for every diff --git a/api/server/services/Files/Code/process.js b/api/server/services/Files/Code/process.js index 62749818cf..adda87b201 100644 --- a/api/server/services/Files/Code/process.js +++ b/api/server/services/Files/Code/process.js @@ -10,6 +10,7 @@ const { sanitizeArtifactPath, flattenArtifactPath, createAxiosInstance, + getCodeApiAuthHeaders, classifyCodeArtifact, codeServerHttpAgent, codeServerHttpsAgent, @@ -335,6 +336,7 @@ const processCodeOutput = async ({ try { const formattedDate = currentDate.toISOString(); + const authHeaders = await getCodeApiAuthHeaders(req); /* Code-output files are always user-private — no skill execution * produces a skill-scoped output bucket. The download URL must * carry `?kind=user&id=` so codeapi's `sessionAuth` @@ -347,6 +349,7 @@ const processCodeOutput = async ({ responseType: 'arraybuffer', headers: { 'User-Agent': 'LibreChat/1.0', + ...authHeaders, }, httpAgent: codeServerHttpAgent, httpsAgent: codeServerHttpsAgent, @@ -669,14 +672,16 @@ function checkIfActive(dateString) { * @param {import('librechat-data-provider').CodeEnvRef} ref - Typed pointer * into codeapi storage. Carries kind/id/storage_session_id/file_id; * codeapi resolves the sessionKey from the request's auth context. + * @param {ServerRequest} [req] - Current authenticated request, used to mint Code API auth. * * @returns {Promise} * A promise that resolves to the `lastModified` time string of the file if successful, or null if there is an * error in initialization or fetching the info. */ -async function getSessionInfo(ref) { +async function getSessionInfo(ref, req) { try { const baseURL = getCodeBaseURL(); + const authHeaders = await getCodeApiAuthHeaders(req); /* `/sessions/.../objects/...` is gated by codeapi's `sessionAuth` * middleware (post-Phase C). The middleware reconstructs the * sessionKey from the URL query (`kind`/`id`/`version?`) plus the @@ -693,6 +698,7 @@ async function getSessionInfo(ref) { url: `${baseURL}/sessions/${ref.storage_session_id}/objects/${ref.file_id}${query}`, headers: { 'User-Agent': 'LibreChat/1.0', + ...authHeaders, }, httpAgent: codeServerHttpAgent, httpsAgent: codeServerHttpsAgent, @@ -925,7 +931,7 @@ const primeFiles = async (options) => { ); } }; - const uploadTime = await getSessionInfo(ref); + const uploadTime = await getSessionInfo(ref, req); if (!uploadTime) { logger.debug( `[primeCodeFiles] file=${file.file_id} path=reupload reason=no-uploadtime ` + @@ -979,9 +985,10 @@ const primeFiles = async (options) => { * @param {string} params.file_path - Absolute path inside the sandbox (e.g. `/mnt/data/foo.txt`). * @param {string} [params.session_id] - Sandbox session id from the seeded context. * @param {Array<{id: string, name: string, session_id?: string}>} [params.files] - File refs to mount. + * @param {ServerRequest} [params.req] - Current authenticated request, used to mint Code API auth. * @returns {Promise<{content: string} | null>} */ -async function readSandboxFile({ file_path, session_id, files }) { +async function readSandboxFile({ file_path, session_id, files, req }) { const baseURL = getCodeBaseURL(); if (!baseURL) { return null; @@ -1002,6 +1009,7 @@ async function readSandboxFile({ file_path, session_id, files }) { } try { + const authHeaders = await getCodeApiAuthHeaders(req); const response = await axios({ method: 'post', url: `${baseURL}/exec`, @@ -1009,6 +1017,7 @@ async function readSandboxFile({ file_path, session_id, files }) { headers: { 'Content-Type': 'application/json', 'User-Agent': 'LibreChat/1.0', + ...authHeaders, }, httpAgent: codeServerHttpAgent, httpsAgent: codeServerHttpsAgent, diff --git a/api/server/services/Files/Code/process.spec.js b/api/server/services/Files/Code/process.spec.js index cd2778f80d..6ade00b1f7 100644 --- a/api/server/services/Files/Code/process.spec.js +++ b/api/server/services/Files/Code/process.spec.js @@ -62,6 +62,7 @@ jest.mock('@librechat/api', () => { sanitizeArtifactPath: jest.fn((name) => name), flattenArtifactPath: jest.fn((name) => name.replace(/\//g, '__')), createAxiosInstance: jest.fn(() => mockAxios), + getCodeApiAuthHeaders: jest.fn(async () => ({})), withTimeout: (...args) => passthroughWithTimeout(...args), hasOfficeHtmlPath: (...args) => mockHasOfficeHtmlPath(...args), /** @@ -148,7 +149,12 @@ const { getStrategyFunctions } = require('~/server/services/Files/strategies'); const { convertImage } = require('~/server/services/Files/images/convert'); const { determineFileType } = require('~/server/utils'); const { logger } = require('@librechat/data-schemas'); -const { codeServerHttpAgent, codeServerHttpsAgent, getStorageMetadata } = require('@librechat/api'); +const { + codeServerHttpAgent, + codeServerHttpsAgent, + getCodeApiAuthHeaders, + getStorageMetadata, +} = require('@librechat/api'); const { processCodeOutput, getSessionInfo, readSandboxFile, primeFiles } = require('./process'); @@ -231,6 +237,24 @@ describe('Code Process', () => { }); describe('processCodeOutput', () => { + it('forwards Code API auth headers when downloading generated output', async () => { + getCodeApiAuthHeaders.mockResolvedValueOnce({ Authorization: 'Bearer codeapi-token' }); + mockAxios.mockResolvedValue({ data: Buffer.alloc(100) }); + + await processCodeOutput(baseParams); + + expect(getCodeApiAuthHeaders).toHaveBeenCalledWith(mockReq); + expect(mockAxios).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'get', + headers: expect.objectContaining({ + Authorization: 'Bearer codeapi-token', + 'User-Agent': 'LibreChat/1.0', + }), + }), + ); + }); + describe('image file processing', () => { it('should process image files using convertImage', async () => { const imageParams = { ...baseParams, name: 'chart.png' }; @@ -1008,6 +1032,34 @@ describe('Code Process', () => { expect(callConfig.httpAgent.keepAlive).toBe(false); expect(callConfig.httpsAgent.keepAlive).toBe(false); }); + + it('forwards Code API auth headers when checking session object freshness', async () => { + getCodeApiAuthHeaders.mockResolvedValueOnce({ Authorization: 'Bearer freshness-token' }); + mockAxios.mockResolvedValue({ + data: { lastModified: '2024-01-01T00:00:00Z' }, + }); + + await getSessionInfo( + { + kind: 'user', + id: 'user-123', + storage_session_id: 'session-123', + file_id: 'file-123', + }, + mockReq, + ); + + expect(getCodeApiAuthHeaders).toHaveBeenCalledWith(mockReq); + expect(mockAxios).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'get', + headers: expect.objectContaining({ + Authorization: 'Bearer freshness-token', + 'User-Agent': 'LibreChat/1.0', + }), + }), + ); + }); }); describe('deferred-preview flow (office-bucket files)', () => { @@ -1465,6 +1517,24 @@ describe('Code Process', () => { expect(call.httpAgent).toBe(codeServerHttpAgent); expect(call.httpsAgent).toBe(codeServerHttpsAgent); }); + + it('forwards Code API auth headers when reading from the sandbox', async () => { + getCodeApiAuthHeaders.mockResolvedValueOnce({ Authorization: 'Bearer sandbox-token' }); + mockAxios.mockResolvedValueOnce({ data: { stdout: 'x', stderr: '' } }); + + await readSandboxFile({ file_path: '/mnt/data/x.txt', req: mockReq }); + + expect(getCodeApiAuthHeaders).toHaveBeenCalledWith(mockReq); + expect(mockAxios).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'post', + headers: expect.objectContaining({ + Authorization: 'Bearer sandbox-token', + 'User-Agent': 'LibreChat/1.0', + }), + }), + ); + }); }); describe('response handling', () => { diff --git a/api/server/services/ToolService.js b/api/server/services/ToolService.js index e15d802973..2e4d533441 100644 --- a/api/server/services/ToolService.js +++ b/api/server/services/ToolService.js @@ -21,6 +21,7 @@ const { buildOAuthToolCallName, buildToolClassification, buildWebSearchDynamicContext, + getCodeApiAuthHeaders, } = require('@librechat/api'); const { Time, @@ -1009,6 +1010,7 @@ async function loadAgentTools({ agentId: agent.id, agentToolOptions: agent.tool_options, deferredToolsEnabled, + authHeaders: () => getCodeApiAuthHeaders(req), }); const agentTools = []; @@ -1279,10 +1281,12 @@ async function loadToolsForExecution({ configurable.toolRegistry = toolRegistry; try { /** - * PTC auth is handled by the agents library / sandbox service - * directly; LibreChat no longer threads a per-run credential. + * LibreChat threads per-request Code API auth through the agents + * library so PTC calls share the same managed auth context. */ - const ptcTool = createProgrammaticToolCallingTool({}); + const ptcTool = createProgrammaticToolCallingTool({ + authHeaders: () => getCodeApiAuthHeaders(req), + }); allLoadedTools.push(ptcTool); } catch (error) { logger.error('[loadToolsForExecution] Error creating PTC tool:', error); @@ -1292,7 +1296,9 @@ async function loadToolsForExecution({ const isBashTool = toolNames.includes(AgentConstants.BASH_TOOL); if (isBashTool) { try { - const bashTool = createBashExecutionTool({}); + const bashTool = createBashExecutionTool({ + authHeaders: () => getCodeApiAuthHeaders(req), + }); allLoadedTools.push(bashTool); } catch (error) { logger.error('[loadToolsForExecution] Failed to create bash_tool', error); diff --git a/package-lock.json b/package-lock.json index 93452126a7..e49a553123 100644 --- a/package-lock.json +++ b/package-lock.json @@ -60,7 +60,7 @@ "@azure/storage-blob": "^12.30.0", "@google/genai": "^1.19.0", "@keyv/redis": "^4.3.3", - "@librechat/agents": "^3.1.83", + "@librechat/agents": "^3.1.84", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", @@ -12088,9 +12088,9 @@ } }, "node_modules/@librechat/agents": { - "version": "3.1.83", - "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.1.83.tgz", - "integrity": "sha512-6d+GOrR9ORe0a+ofwcJLWXbEP5VIurKxu4bWnOPpLMj5+rPDeTPgfGrmGwPrREQPTkFWpMb8VkEb7iP2ve3XzA==", + "version": "3.1.84", + "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.1.84.tgz", + "integrity": "sha512-scAAdh11aHJlvVVACl6FaiGdz+UZDVkSUb99PZjNAql6BQ//I/pgzBrZ10Q4VNp65cMRYPI6GqAAGQb2MFCA/Q==", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "^0.92.0", @@ -44658,7 +44658,7 @@ "@azure/storage-blob": "^12.30.0", "@google/genai": "^1.19.0", "@keyv/redis": "^4.3.3", - "@librechat/agents": "^3.1.83", + "@librechat/agents": "^3.1.84", "@librechat/data-schemas": "*", "@modelcontextprotocol/sdk": "^1.29.0", "@smithy/node-http-handler": "^4.4.5", diff --git a/packages/api/package.json b/packages/api/package.json index ab0c76608f..43eedb53f7 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -98,7 +98,7 @@ "@azure/storage-blob": "^12.30.0", "@google/genai": "^1.19.0", "@keyv/redis": "^4.3.3", - "@librechat/agents": "^3.1.83", + "@librechat/agents": "^3.1.84", "@librechat/data-schemas": "*", "@modelcontextprotocol/sdk": "^1.29.0", "@smithy/node-http-handler": "^4.4.5", diff --git a/packages/api/src/agents/handlers.ts b/packages/api/src/agents/handlers.ts index 05f892eff8..c8153ba277 100644 --- a/packages/api/src/agents/handlers.ts +++ b/packages/api/src/agents/handlers.ts @@ -104,7 +104,7 @@ export interface ToolExecuteOptions { files: Array<{ fileId: string; filename: string }>; }>; /** Checks if a code env file is still active. Returns lastModified or null. */ - getSessionInfo?: (ref: CodeEnvRef) => Promise; + getSessionInfo?: (ref: CodeEnvRef, req?: ServerRequest) => Promise; /** 23-hour freshness check */ checkIfActive?: (dateString: string) => boolean; /** Persists `codeEnvRef` on skill files after upload */ @@ -147,6 +147,7 @@ export interface ToolExecuteOptions { file_path: string; session_id?: string; files?: Array<{ id: string; name: string; session_id?: string }>; + req?: ServerRequest; }) => Promise<{ content: string } | null>; } @@ -332,6 +333,7 @@ async function handleSandboxFileFallback( tc: ToolCallRequest, filePath: string, options: ToolExecuteOptions, + req?: ServerRequest, ): Promise { const ext = lowercaseExtension(filePath); if (BINARY_EXTENSIONS_NEVER_READABLE.has(ext)) { @@ -361,6 +363,7 @@ async function handleSandboxFileFallback( file_path: filePath, session_id: ctx?.session_id, files: ctx?.files, + ...(req ? { req } : {}), }); if (!result || result.content == null) { return { @@ -450,7 +453,7 @@ async function handleReadFileCall( */ if (args.file_path.startsWith('/mnt/data/')) { if (codeEnvAvailable) { - return handleSandboxFileFallback(tc, args.file_path, options); + return handleSandboxFileFallback(tc, args.file_path, options, req); } return { toolCallId: tc.id, @@ -463,7 +466,7 @@ async function handleReadFileCall( const slashIdx = args.file_path.indexOf('/'); if (slashIdx < 1) { if (codeEnvAvailable) { - return handleSandboxFileFallback(tc, args.file_path, options); + return handleSandboxFileFallback(tc, args.file_path, options, req); } return { toolCallId: tc.id, @@ -483,7 +486,7 @@ async function handleReadFileCall( * dead-ending with a skill-centric error message. */ if (codeEnvAvailable) { - return handleSandboxFileFallback(tc, args.file_path, options); + return handleSandboxFileFallback(tc, args.file_path, options, req); } return { toolCallId: tc.id, @@ -502,7 +505,7 @@ async function handleReadFileCall( */ if (!skillsEffectivelyEnabled) { if (codeEnvAvailable) { - return handleSandboxFileFallback(tc, args.file_path, options); + return handleSandboxFileFallback(tc, args.file_path, options, req); } return { toolCallId: tc.id, @@ -544,7 +547,7 @@ async function handleReadFileCall( const activeSkillNames = mergedConfigurable?.activeSkillNames as Set | undefined; if (activeSkillNames && !activeSkillNames.has(skillName) && !isPrimedThisTurn) { if (codeEnvAvailable) { - return handleSandboxFileFallback(tc, args.file_path, options); + return handleSandboxFileFallback(tc, args.file_path, options, req); } return { toolCallId: tc.id, diff --git a/packages/api/src/agents/skillFiles.spec.ts b/packages/api/src/agents/skillFiles.spec.ts index 4338b34e4e..a571e9a0af 100644 --- a/packages/api/src/agents/skillFiles.spec.ts +++ b/packages/api/src/agents/skillFiles.spec.ts @@ -274,17 +274,28 @@ describe('primeInvokedSkills — execute_code capability gate', () => { }, ]); const batchUploadCodeEnvFiles = jest.fn(); + const getSessionInfo = jest.fn().mockResolvedValue('2026-05-06T00:00:00Z'); const deps = makeDeps({ codeEnvAvailable: true, listSkillFiles, batchUploadCodeEnvFiles, - getSessionInfo: jest.fn().mockResolvedValue('2026-05-06T00:00:00Z'), + getSessionInfo, checkIfActive: jest.fn().mockReturnValue(true), }); const result = await primeInvokedSkills(deps); expect(batchUploadCodeEnvFiles).not.toHaveBeenCalled(); + expect(getSessionInfo).toHaveBeenCalledWith( + { + kind: 'skill', + id: SKILL_ID.toString(), + storage_session_id: 'session-cached', + file_id: 'file-cached', + version: SKILL_VERSION, + }, + deps.req, + ); const codeSession = result.initialSessions?.get('execute_code'); expect(codeSession?.files).toEqual([ { diff --git a/packages/api/src/agents/skillFiles.ts b/packages/api/src/agents/skillFiles.ts index dc48914b83..3081d4b332 100644 --- a/packages/api/src/agents/skillFiles.ts +++ b/packages/api/src/agents/skillFiles.ts @@ -51,7 +51,7 @@ export interface PrimeSkillFilesParams { files: Array<{ fileId: string; filename: string }>; }>; /** Checks if a code env file is still active. Returns lastModified timestamp or null. */ - getSessionInfo?: (ref: CodeEnvRef) => Promise; + getSessionInfo?: (ref: CodeEnvRef, req?: ServerRequest) => Promise; /** 23-hour freshness check */ checkIfActive?: (dateString: string) => boolean; /** Persists `codeEnvRef` on skill files after upload. Implementations @@ -128,7 +128,7 @@ export async function primeSkillFiles( try { const checkResults = await Promise.all( Array.from(refsBySession.values()).map(async (ref) => { - const lastModified = await getSessionInfo(ref); + const lastModified = await getSessionInfo(ref, req); return !!(lastModified && checkIfActive(lastModified)); }), ); @@ -421,7 +421,7 @@ export async function primeInvokedSkills( const checkResults = await Promise.all( Array.from(refsBySession.values()).map(async (ref) => { try { - const lastModified = await deps.getSessionInfo?.(ref); + const lastModified = await deps.getSessionInfo?.(ref, deps.req); return !!(lastModified && deps.checkIfActive?.(lastModified)); } catch { return false; @@ -470,7 +470,7 @@ export async function primeInvokedSkills( // Per-skill upload: each skill gets its own storage session keyed // by `(kind: 'skill', id: skillId, version: skill.version)`. // primeSkillFiles handles freshness caching per-skill, so only - // expired skills re-upload. CodeAPI handles mixed + // expired skills re-upload. Code API handles mixed // storage_session_ids natively. const allPrimedFiles: Array<{ id: string; diff --git a/packages/api/src/auth/codeapi.spec.ts b/packages/api/src/auth/codeapi.spec.ts new file mode 100644 index 0000000000..4e678ac048 --- /dev/null +++ b/packages/api/src/auth/codeapi.spec.ts @@ -0,0 +1,281 @@ +import { createHash, generateKeyPairSync, verify as cryptoVerify } from 'crypto'; +import type { KeyObject } from 'crypto'; +import type { ServerRequest } from '~/types'; +import { getCodeApiAuthHeaders, mintCodeApiToken } from './codeapi'; + +jest.mock( + '@librechat/data-schemas', + () => ({ + getTenantId: jest.fn(), + }), + { virtual: true }, +); + +jest.mock('~/utils', () => ({ + isEnabled: (value?: string) => value === 'true' || value === '1', +})); + +const mockGetTenantId = jest.requireMock('@librechat/data-schemas').getTenantId as jest.Mock; + +const ENV_KEYS = [ + 'CODEAPI_AUTH_PROVIDER', + 'CODEAPI_JWT_ENABLED', + 'CODEAPI_JWT_PRIVATE_KEY', + 'CODEAPI_JWT_PRIVATE_KEY_BASE64', + 'CODEAPI_JWT_PRIVATE_JWK_JSON', + 'CODEAPI_JWT_ALGORITHM', + 'CODEAPI_JWT_KID', + 'CODEAPI_JWT_KEY_ID', + 'CODEAPI_JWT_ISSUER', + 'CODEAPI_JWT_AUDIENCE', + 'CODEAPI_JWT_TTL_SECONDS', + 'CODEAPI_JWT_MINT_CACHE_SECONDS', + 'CODEAPI_JWT_SINGLE_TENANT_ID', + 'TENANT_ISOLATION_STRICT', + 'OPENID_REUSE_TOKENS', +] as const; + +type Claims = Record; + +function baseRequest(overrides: Record = {}): ServerRequest { + return { + user: { + _id: { toString: () => 'user_123' }, + tenantId: 'tenant_abc', + role: 'USER', + provider: 'local', + ...overrides, + }, + body: { tenant_id: 'spoofed_tenant' }, + headers: { 'x-tenant-id': 'spoofed_header_tenant' }, + } as unknown as ServerRequest; +} + +function decodeToken(token: string): { + header: Claims; + claims: Claims; + signingInput: string; + signature: Buffer; +} { + const [header, payload, signature] = token.split('.') as [string, string, string]; + return { + header: JSON.parse(Buffer.from(header, 'base64url').toString('utf8')) as Claims, + claims: JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as Claims, + signingInput: `${header}.${payload}`, + signature: Buffer.from(signature, 'base64url'), + }; +} + +function expectedContextHash(input: { + userId: string; + tenantId: string; + role: string; + principalSource: string; + orgId?: string; + serviceId?: string; + chcUserId?: string; +}): string { + return createHash('sha256') + .update( + JSON.stringify({ + chc_user_id: input.chcUserId ?? '', + org_id: input.orgId ?? '', + principal_source: input.principalSource, + role: input.role, + service_id: input.serviceId ?? '', + sub: input.userId, + tenant_id: input.tenantId, + }), + ) + .digest('hex'); +} + +describe('Code API JWT minting', () => { + const originalEnv = new Map(); + let publicKey: KeyObject; + + beforeAll(() => { + for (const key of ENV_KEYS) { + originalEnv.set(key, process.env[key]); + } + }); + + beforeEach(() => { + const keyPair = generateKeyPairSync('ed25519'); + publicKey = keyPair.publicKey; + process.env.CODEAPI_AUTH_PROVIDER = 'librechat-jwt'; + process.env.CODEAPI_JWT_PRIVATE_JWK_JSON = JSON.stringify( + keyPair.privateKey.export({ format: 'jwk' }), + ); + process.env.CODEAPI_JWT_ALGORITHM = 'EdDSA'; + process.env.CODEAPI_JWT_KID = 'test-kid'; + process.env.CODEAPI_JWT_ISSUER = 'librechat'; + process.env.CODEAPI_JWT_AUDIENCE = 'codeapi'; + process.env.CODEAPI_JWT_TTL_SECONDS = '300'; + process.env.CODEAPI_JWT_MINT_CACHE_SECONDS = '30'; + delete process.env.CODEAPI_JWT_PRIVATE_KEY; + delete process.env.CODEAPI_JWT_PRIVATE_KEY_BASE64; + delete process.env.CODEAPI_JWT_SINGLE_TENANT_ID; + delete process.env.TENANT_ISOLATION_STRICT; + delete process.env.OPENID_REUSE_TOKENS; + mockGetTenantId.mockReset(); + }); + + afterAll(() => { + for (const key of ENV_KEYS) { + const value = originalEnv.get(key); + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + }); + + it('mints a Code API-scoped token from canonical LibreChat JWT context', async () => { + jest.spyOn(Date, 'now').mockReturnValue(1_778_250_000_000); + + const token = await mintCodeApiToken(baseRequest()); + const decoded = decodeToken(token); + + expect(decoded.header).toEqual({ + alg: 'EdDSA', + typ: 'JWT', + kid: 'test-kid', + }); + expect( + cryptoVerify(null, Buffer.from(decoded.signingInput), publicKey, decoded.signature), + ).toBe(true); + expect(decoded.claims).toMatchObject({ + iss: 'librechat', + aud: 'codeapi', + sub: 'user_123', + iat: 1_778_250_000, + nbf: 1_778_250_000, + exp: 1_778_250_300, + tenant_id: 'tenant_abc', + role: 'USER', + principal_source: 'librechat_jwt', + }); + expect(decoded.claims.auth_context_hash).toBe( + expectedContextHash({ + userId: 'user_123', + tenantId: 'tenant_abc', + role: 'USER', + principalSource: 'librechat_jwt', + }), + ); + expect(decoded.claims).not.toHaveProperty('refresh_token'); + expect(decoded.claims).not.toHaveProperty('openid_token'); + }); + + it('marks OpenID reuse callers without forwarding upstream credentials', async () => { + process.env.OPENID_REUSE_TOKENS = 'true'; + const req = baseRequest({ + provider: 'openid', + idOnTheSource: 'chc_user_123', + refreshToken: 'do-not-forward', + accessToken: 'do-not-forward', + }); + req.authStrategy = 'openidJwt'; + + const token = await mintCodeApiToken(req); + const { claims } = decodeToken(token); + + expect(claims).toMatchObject({ + principal_source: 'openid_reuse', + chc_user_id: 'chc_user_123', + }); + expect(JSON.stringify(claims)).not.toContain('do-not-forward'); + }); + + it('marks OpenID users authenticated by LibreChat JWT as LibreChat JWT callers', async () => { + process.env.OPENID_REUSE_TOKENS = 'true'; + + const token = await mintCodeApiToken(baseRequest({ provider: 'openid' })); + const { claims } = decodeToken(token); + + expect(claims.principal_source).toBe('librechat_jwt'); + }); + + it('includes optional plan context when present without trusting caller input', async () => { + const token = await mintCodeApiToken( + baseRequest({ + subscription: { planId: 'prod_plan_123' }, + }), + ); + const { claims } = decodeToken(token); + + expect(claims.plan_id).toBe('prod_plan_123'); + expect(claims).not.toHaveProperty('planId'); + }); + + it('uses the single-tenant namespace when tenant context is absent outside strict mode', async () => { + mockGetTenantId.mockReturnValue(undefined); + + const token = await mintCodeApiToken(baseRequest({ tenantId: undefined })); + const { claims } = decodeToken(token); + + expect(claims.tenant_id).toBe('legacy'); + expect(claims.auth_context_hash).toBe( + expectedContextHash({ + userId: 'user_123', + tenantId: 'legacy', + role: 'USER', + principalSource: 'librechat_jwt', + }), + ); + }); + + it('supports overriding the single-tenant namespace for local deployments', async () => { + process.env.CODEAPI_JWT_SINGLE_TENANT_ID = 'local-single-tenant'; + mockGetTenantId.mockReturnValue(undefined); + + const token = await mintCodeApiToken(baseRequest({ tenantId: undefined })); + const { claims } = decodeToken(token); + + expect(claims.tenant_id).toBe('local-single-tenant'); + }); + + it('rejects minting without tenant context in strict tenant mode', async () => { + process.env.TENANT_ISOLATION_STRICT = 'true'; + mockGetTenantId.mockReturnValue(undefined); + + await expect(mintCodeApiToken(baseRequest({ tenantId: undefined }))).rejects.toThrow( + 'Code API JWT auth requires tenant context', + ); + }); + + it('ignores caller-supplied tenant spoofing fields', async () => { + const token = await mintCodeApiToken(baseRequest({ tenantId: 'tenant_canonical' })); + const { claims } = decodeToken(token); + + expect(claims.tenant_id).toBe('tenant_canonical'); + expect(JSON.stringify(claims)).not.toContain('spoofed_tenant'); + expect(JSON.stringify(claims)).not.toContain('spoofed_header_tenant'); + }); + + it('caches minted tokens for at most the configured 30 second window', async () => { + const now = jest.spyOn(Date, 'now').mockReturnValue(1_778_250_000_000); + const req = baseRequest(); + + const first = await mintCodeApiToken(req); + const second = await mintCodeApiToken(req); + now.mockReturnValue(1_778_250_031_000); + const afterCacheWindow = await mintCodeApiToken(req); + + expect(second).toBe(first); + expect(afterCacheWindow).not.toBe(first); + }); + + it('returns Authorization headers only when a request and managed auth are present', async () => { + await expect(getCodeApiAuthHeaders()).resolves.toEqual({}); + await expect(getCodeApiAuthHeaders(baseRequest())).resolves.toEqual({ + Authorization: expect.stringMatching(/^Bearer /), + }); + + process.env.CODEAPI_AUTH_PROVIDER = 'legacy-api-key'; + delete process.env.CODEAPI_JWT_ENABLED; + await expect(getCodeApiAuthHeaders(baseRequest())).resolves.toEqual({}); + }); +}); diff --git a/packages/api/src/auth/codeapi.ts b/packages/api/src/auth/codeapi.ts new file mode 100644 index 0000000000..ab017bb9e4 --- /dev/null +++ b/packages/api/src/auth/codeapi.ts @@ -0,0 +1,380 @@ +import { getTenantId } from '@librechat/data-schemas'; +import { createHash, createPrivateKey, randomUUID, sign as cryptoSign } from 'crypto'; +import type { KeyObject, JsonWebKey } from 'crypto'; +import type { ServerRequest } from '~/types'; +import { isEnabled } from '~/utils'; + +type CodeApiJwtAlg = 'EdDSA' | 'RS256'; +type PrincipalSource = 'librechat_jwt' | 'openid_reuse'; + +interface CodeApiUserContext { + id?: string; + _id?: { toString(): string }; + role?: string; + tenantId?: string | { toString(): string }; + provider?: string; + orgId?: string; + serviceId?: string; + chcUserId?: string; + idOnTheSource?: string; + planId?: string; + subscription?: { + planId?: string; + }; +} + +interface CodeApiClaims { + iss: string; + aud: string; + sub: string; + iat: number; + nbf: number; + exp: number; + jti: string; + tenant_id: string; + role: string; + principal_source: PrincipalSource; + org_id?: string; + service_id?: string; + chc_user_id?: string; + plan_id?: string; + auth_context_hash: string; +} + +interface SigningConfig { + alg: CodeApiJwtAlg; + kid: string; + issuer: string; + audience: string; + ttlSeconds: number; + cacheSeconds: number; + key: KeyObject; + rawKey: string; +} + +interface CachedToken { + token: string; + expiresAt: number; + cachedUntil: number; +} + +const DEFAULT_ISSUER = 'librechat'; +const DEFAULT_AUDIENCE = 'codeapi'; +const DEFAULT_KID = 'lc-codeapi-2026-05'; +const DEFAULT_SINGLE_TENANT_ID = 'legacy'; +const DEFAULT_TTL_SECONDS = 300; +const DEFAULT_CACHE_SECONDS = 30; +const MAX_TTL_SECONDS = 300; +const MAX_CACHE_SECONDS = 30; +const TOKEN_REUSE_SAFETY_WINDOW_SECONDS = 30; +const TOKEN_CACHE_PRUNE_INTERVAL_SECONDS = 30; + +let signingConfigCache: SigningConfig | null = null; +const tokenCache = new Map(); +let tokenCacheLastPrunedAt = 0; + +function base64Url(value: Buffer | string): string { + return Buffer.from(value).toString('base64url'); +} + +function normalizePem(value: string): string { + return value.replace(/\\n/g, '\n').trim(); +} + +function getPrivateKeyRaw(): string { + const inlineKey = process.env.CODEAPI_JWT_PRIVATE_KEY; + if (inlineKey != null && inlineKey.trim() !== '') { + return normalizePem(inlineKey); + } + const base64Key = process.env.CODEAPI_JWT_PRIVATE_KEY_BASE64; + if (base64Key != null && base64Key.trim() !== '') { + return normalizePem(Buffer.from(base64Key, 'base64').toString('utf8')); + } + const jwkJson = process.env.CODEAPI_JWT_PRIVATE_JWK_JSON; + if (jwkJson != null && jwkJson.trim() !== '') { + return jwkJson.trim(); + } + throw new Error('Code API JWT signing key is not configured'); +} + +function parseAlg(value: string | undefined): CodeApiJwtAlg { + if (value === 'RS256') { + return 'RS256'; + } + if (value === undefined || value === '' || value === 'EdDSA') { + return 'EdDSA'; + } + throw new Error(`Unsupported Code API JWT algorithm: ${value}`); +} + +function parseCappedSeconds(value: string | undefined, fallback: number, max: number): number { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + return fallback; + } + return Math.min(Math.floor(parsed), max); +} + +function createSigningKey(rawKey: string): KeyObject { + if (rawKey.startsWith('{')) { + return createPrivateKey({ key: JSON.parse(rawKey) as JsonWebKey, format: 'jwk' }); + } + return createPrivateKey(rawKey); +} + +function getSigningConfig(): SigningConfig { + const rawKey = getPrivateKeyRaw(); + const alg = parseAlg(process.env.CODEAPI_JWT_ALGORITHM); + const kid = process.env.CODEAPI_JWT_KID ?? process.env.CODEAPI_JWT_KEY_ID ?? DEFAULT_KID; + const issuer = process.env.CODEAPI_JWT_ISSUER ?? DEFAULT_ISSUER; + const audience = process.env.CODEAPI_JWT_AUDIENCE ?? DEFAULT_AUDIENCE; + const ttlSeconds = parseCappedSeconds( + process.env.CODEAPI_JWT_TTL_SECONDS, + DEFAULT_TTL_SECONDS, + MAX_TTL_SECONDS, + ); + const cacheSeconds = parseCappedSeconds( + process.env.CODEAPI_JWT_MINT_CACHE_SECONDS, + DEFAULT_CACHE_SECONDS, + MAX_CACHE_SECONDS, + ); + + if ( + signingConfigCache && + signingConfigCache.rawKey === rawKey && + signingConfigCache.alg === alg && + signingConfigCache.kid === kid && + signingConfigCache.issuer === issuer && + signingConfigCache.audience === audience && + signingConfigCache.ttlSeconds === ttlSeconds && + signingConfigCache.cacheSeconds === cacheSeconds + ) { + return signingConfigCache; + } + + signingConfigCache = { + alg, + kid, + issuer, + audience, + ttlSeconds, + cacheSeconds, + rawKey, + key: createSigningKey(rawKey), + }; + tokenCache.clear(); + tokenCacheLastPrunedAt = 0; + return signingConfigCache; +} + +function stringifyClaimValue(value: unknown): string | undefined { + if (typeof value === 'string' && value.trim() !== '') { + return value; + } + if (value && typeof value === 'object' && 'toString' in value) { + const stringValue = value.toString(); + return stringValue.trim() === '' ? undefined : stringValue; + } + return undefined; +} + +function resolveUser(req: ServerRequest): CodeApiUserContext { + const user = req.user as CodeApiUserContext | undefined; + if (!user) { + throw new Error('Code API token minting requires an authenticated user'); + } + return user; +} + +function resolveUserId(user: CodeApiUserContext): string { + const userId = stringifyClaimValue(user.id) ?? stringifyClaimValue(user._id); + if (!userId) { + throw new Error('Code API token minting requires a canonical user id'); + } + return userId; +} + +function resolveSingleTenantId(): string { + const configured = process.env.CODEAPI_JWT_SINGLE_TENANT_ID; + if (configured != null && configured.trim() !== '') { + return configured.trim(); + } + return DEFAULT_SINGLE_TENANT_ID; +} + +function resolveTenantId(user: CodeApiUserContext): string | undefined { + const tenantId = stringifyClaimValue(user.tenantId) ?? getTenantId(); + if (tenantId) { + return tenantId; + } + if (isEnabled(process.env.TENANT_ISOLATION_STRICT)) { + return undefined; + } + return resolveSingleTenantId(); +} + +function isManagedCodeApiJwtMode(): boolean { + const provider = process.env.CODEAPI_AUTH_PROVIDER; + return provider === 'librechat-jwt' || provider === 'both'; +} + +export function isCodeApiJwtAuthEnabled(): boolean { + return isManagedCodeApiJwtMode() || isEnabled(process.env.CODEAPI_JWT_ENABLED); +} + +function resolvePrincipalSource(req: ServerRequest): PrincipalSource { + if (req.authStrategy === 'openidJwt') { + return 'openid_reuse'; + } + return 'librechat_jwt'; +} + +function canonicalContextHash(input: { + userId: string; + tenantId: string; + role: string; + principalSource: PrincipalSource; + orgId?: string; + serviceId?: string; + chcUserId?: string; +}): string { + const canonical = { + chc_user_id: input.chcUserId ?? '', + org_id: input.orgId ?? '', + principal_source: input.principalSource, + role: input.role, + service_id: input.serviceId ?? '', + sub: input.userId, + tenant_id: input.tenantId, + }; + return createHash('sha256').update(JSON.stringify(canonical)).digest('hex'); +} + +function buildClaims(req: ServerRequest, config: SigningConfig, now: number): CodeApiClaims { + const user = resolveUser(req); + const userId = resolveUserId(user); + const tenantId = resolveTenantId(user); + if (!tenantId) { + throw new Error('Code API JWT auth requires tenant context'); + } + + const role = user.role ?? 'USER'; + const principalSource = resolvePrincipalSource(req); + const orgId = stringifyClaimValue(user.orgId); + const serviceId = stringifyClaimValue(user.serviceId); + const chcUserId = stringifyClaimValue(user.chcUserId) ?? stringifyClaimValue(user.idOnTheSource); + const planId = stringifyClaimValue(user.planId) ?? stringifyClaimValue(user.subscription?.planId); + const authContextHash = canonicalContextHash({ + userId, + tenantId, + role, + principalSource, + orgId, + serviceId, + chcUserId, + }); + + return { + iss: config.issuer, + aud: config.audience, + sub: userId, + iat: now, + nbf: now, + exp: now + config.ttlSeconds, + jti: randomUUID(), + tenant_id: tenantId, + role, + principal_source: principalSource, + ...(orgId ? { org_id: orgId } : {}), + ...(serviceId ? { service_id: serviceId } : {}), + ...(chcUserId ? { chc_user_id: chcUserId } : {}), + ...(planId ? { plan_id: planId } : {}), + auth_context_hash: authContextHash, + }; +} + +function signJwt(config: SigningConfig, claims: CodeApiClaims): string { + const header = { + alg: config.alg, + typ: 'JWT', + kid: config.kid, + }; + const signingInput = `${base64Url(JSON.stringify(header))}.${base64Url(JSON.stringify(claims))}`; + const signature = cryptoSign( + config.alg === 'RS256' ? 'RSA-SHA256' : null, + Buffer.from(signingInput), + config.key, + ); + return `${signingInput}.${base64Url(signature)}`; +} + +function cacheKey(config: SigningConfig, claims: CodeApiClaims): string { + return [ + config.alg, + config.kid, + claims.sub, + claims.tenant_id, + claims.role, + claims.principal_source, + claims.org_id ?? '', + claims.service_id ?? '', + claims.chc_user_id ?? '', + claims.plan_id ?? '', + claims.auth_context_hash, + ].join(':'); +} + +function pruneTokenCache(now: number): void { + if (tokenCache.size === 0) { + return; + } + if (now - tokenCacheLastPrunedAt < TOKEN_CACHE_PRUNE_INTERVAL_SECONDS) { + return; + } + + tokenCacheLastPrunedAt = now; + for (const [key, cached] of tokenCache) { + if (cached.cachedUntil <= now || cached.expiresAt <= now + TOKEN_REUSE_SAFETY_WINDOW_SECONDS) { + tokenCache.delete(key); + } + } +} + +export async function mintCodeApiToken(req: ServerRequest): Promise { + if (!isCodeApiJwtAuthEnabled()) { + return ''; + } + + const config = getSigningConfig(); + const now = Math.floor(Date.now() / 1000); + pruneTokenCache(now); + const claims = buildClaims(req, config, now); + const key = cacheKey(config, claims); + const cached = tokenCache.get(key); + if ( + cached && + cached.cachedUntil > now && + cached.expiresAt > now + TOKEN_REUSE_SAFETY_WINDOW_SECONDS + ) { + return cached.token; + } + + const token = signJwt(config, claims); + tokenCache.set(key, { + token, + expiresAt: claims.exp, + cachedUntil: Math.min( + now + config.cacheSeconds, + claims.exp - TOKEN_REUSE_SAFETY_WINDOW_SECONDS, + ), + }); + return token; +} + +export async function getCodeApiAuthHeaders(req?: ServerRequest): Promise> { + if (!req || !isCodeApiJwtAuthEnabled()) { + return {}; + } + const token = await mintCodeApiToken(req); + return token ? { Authorization: `Bearer ${token}` } : {}; +} diff --git a/packages/api/src/auth/index.ts b/packages/api/src/auth/index.ts index d3c8a02896..d3e08deb14 100644 --- a/packages/api/src/auth/index.ts +++ b/packages/api/src/auth/index.ts @@ -5,3 +5,4 @@ export * from './refresh'; export * from './agent'; export * from './password'; export * from './invite'; +export * from './codeapi'; diff --git a/packages/api/src/tools/classification.ts b/packages/api/src/tools/classification.ts index e945b43101..3214562102 100644 --- a/packages/api/src/tools/classification.ts +++ b/packages/api/src/tools/classification.ts @@ -187,6 +187,8 @@ export interface BuildToolClassificationParams { deferredToolsEnabled?: boolean; /** When true, skip creating tool instances (for event-driven mode) */ definitionsOnly?: boolean; + /** Optional host-supplied Code API auth headers for remote programmatic execution. */ + authHeaders?: () => Promise> | Record; } /** Result from building tool classification */ @@ -251,6 +253,7 @@ export async function buildToolClassification( agentToolOptions, definitionsOnly = false, deferredToolsEnabled = true, + authHeaders, } = params; const additionalTools: GenericTool[] = []; @@ -345,7 +348,9 @@ export async function buildToolClassification( } try { - const ptcTool = createProgrammaticToolCallingTool({}); + const ptcTool = createProgrammaticToolCallingTool({ authHeaders } as Parameters< + typeof createProgrammaticToolCallingTool + >[0] & { authHeaders?: BuildToolClassificationParams['authHeaders'] }); additionalTools.push(ptcTool); /** Add PTC definition for event-driven mode */ diff --git a/packages/api/src/types/http.ts b/packages/api/src/types/http.ts index bc7b6dc943..219b2729f7 100644 --- a/packages/api/src/types/http.ts +++ b/packages/api/src/types/http.ts @@ -25,4 +25,6 @@ export type ServerRequest = Request & { conversationCreatedAt?: string; /** Conversation loaded while resolving the prompt timestamp anchor, reused by save logic. */ resolvedConversation?: Partial | null; + /** Passport strategy that populated req.user for this request. */ + authStrategy?: string; }; diff --git a/packages/data-provider/src/codeEnvRef.ts b/packages/data-provider/src/codeEnvRef.ts index 469592bfdc..81187ff78f 100644 --- a/packages/data-provider/src/codeEnvRef.ts +++ b/packages/data-provider/src/codeEnvRef.ts @@ -5,14 +5,14 @@ * both at once. * * - `skill`: shared per skill identity. Cross-user-within-tenant - * sharing. CodeAPI sessionKey omits the user dimension. + * sharing. Code API sessionKey omits the user dimension. * `version` is required (the skill's monotonic counter scopes the * cache per revision so any edit invalidates the prior cache * entry naturally). * - `agent`: shared per agent identity. Same sharing semantic as * skills (agents are addressable resources accessible to a * permission-defined audience). - * - `user`: user-private. CodeAPI sessionKey is keyed by the + * - `user`: user-private. Code API sessionKey is keyed by the * requesting user from auth context. Used for chat attachments * and code-output artifacts. */ @@ -30,7 +30,7 @@ export type CodeEnvKind = (typeof CODE_ENV_KINDS)[number]; * sandbox-run session. * * `kind` and `id` together name the resource that owns this file's - * storage session. CodeAPI uses them (plus the auth-context tenant + * storage session. Code API uses them (plus the auth-context tenant * id) to derive the sessionKey, which determines who shares the * cache. Cross-user sharing for shared resources (skills, agents) is * a designed property of the kind switch, not an emergent side