From 85ab1b1e2f16aa56c8a8be4fccd9a055f4abc7e9 Mon Sep 17 00:00:00 2001 From: Atef Bellaaj Date: Sun, 15 Mar 2026 22:25:30 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=90=20feat(auth):=20add=20CloudFront?= =?UTF-8?q?=20signed=20cookie=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Integrate CloudFront signed cookies into authentication flow for secure CDN access. - Set cookies on login (setAuthTokens, setOpenIDAuthTokens) when imageSigning="cookies". - Clear cookies on logout (clearCloudFrontCookies) to prevent post-logout CDN access. - Add cookieDomain and cookieExpiry config options with validation requiring shared parent domain. - Tighten schema: cookieDomain requires min(1), leading-dot validation, cookieExpiry capped at 604800s (7 days). - Return false from initializeCloudFront when imageSigning="cookies" but signing keys are absent. - Refactor cloudfront-cookies.ts: single config read, remove isCloudFrontCookiesEnabled export, add DEFAULT_COOKIE_EXPIRY fallback const for robustness against missing Zod defaults. - Move cloudfront-cookies.ts from auth/ to cdn/ layer; cookies are CDN access grants, not auth tokens. - Fix clearCloudFrontCookies: add httpOnly/secure/sameSite security attributes and try-catch error guard. - Add REQUIRED_CF_COOKIES validation: assert all 3 cookies present before setting any; log error on missing key. - Scope CloudFront policy resource and cookie path to /images (was /* and /); limits CDN grant to image content. - Fix trailing-slash regex to /\/+$/ to handle multiple slashes; upgrade signing failure log to error level. - Clarify urlExpiry in librechat.example.yaml as reserved for future signed-URL mode. --- .../controllers/auth/LogoutController.js | 3 +- .../controllers/auth/LogoutController.spec.js | 15 +- api/server/services/AuthService.js | 7 + api/server/services/AuthService.spec.js | 70 ++- api/server/services/Files/images/encode.js | 7 +- librechat.example.yaml | 52 +-- .../cdn/__tests__/cloudfront-cookies.test.ts | 407 ++++++++++++++++++ .../api/src/cdn/__tests__/cloudfront.test.ts | 35 +- packages/api/src/cdn/cloudfront-cookies.ts | 109 +++++ packages/api/src/cdn/cloudfront.ts | 22 +- packages/api/src/cdn/index.ts | 1 + .../src/__tests__/cloudfront-config.test.ts | 29 ++ packages/data-provider/src/config.ts | 13 + 13 files changed, 727 insertions(+), 43 deletions(-) create mode 100644 packages/api/src/cdn/__tests__/cloudfront-cookies.test.ts create mode 100644 packages/api/src/cdn/cloudfront-cookies.ts create mode 100644 packages/data-provider/src/__tests__/cloudfront-config.test.ts diff --git a/api/server/controllers/auth/LogoutController.js b/api/server/controllers/auth/LogoutController.js index 039ed630c225..f4253a505ee1 100644 --- a/api/server/controllers/auth/LogoutController.js +++ b/api/server/controllers/auth/LogoutController.js @@ -1,5 +1,5 @@ const cookies = require('cookie'); -const { isEnabled } = require('@librechat/api'); +const { isEnabled, clearCloudFrontCookies } = require('@librechat/api'); const { logger } = require('@librechat/data-schemas'); const { logoutUser } = require('~/server/services/AuthService'); const { getOpenIdConfig } = require('~/strategies'); @@ -28,6 +28,7 @@ const logoutController = async (req, res) => { res.clearCookie('openid_id_token'); res.clearCookie('openid_user_id'); res.clearCookie('token_provider'); + clearCloudFrontCookies(res); const response = { message }; if ( isOpenIdUser && diff --git a/api/server/controllers/auth/LogoutController.spec.js b/api/server/controllers/auth/LogoutController.spec.js index 3f2a2de8e1a0..2305ea212773 100644 --- a/api/server/controllers/auth/LogoutController.spec.js +++ b/api/server/controllers/auth/LogoutController.spec.js @@ -4,9 +4,13 @@ const mockLogoutUser = jest.fn(); const mockLogger = { warn: jest.fn(), error: jest.fn() }; const mockIsEnabled = jest.fn(); const mockGetOpenIdConfig = jest.fn(); +const mockClearCloudFrontCookies = jest.fn(); jest.mock('cookie'); -jest.mock('@librechat/api', () => ({ isEnabled: (...args) => mockIsEnabled(...args) })); +jest.mock('@librechat/api', () => ({ + isEnabled: (...args) => mockIsEnabled(...args), + clearCloudFrontCookies: (...args) => mockClearCloudFrontCookies(...args), +})); jest.mock('@librechat/data-schemas', () => ({ logger: mockLogger })); jest.mock('~/server/services/AuthService', () => ({ logoutUser: (...args) => mockLogoutUser(...args), @@ -255,5 +259,14 @@ describe('LogoutController', () => { expect(res.clearCookie).toHaveBeenCalledWith('openid_user_id'); expect(res.clearCookie).toHaveBeenCalledWith('token_provider'); }); + + it('calls clearCloudFrontCookies on successful logout', async () => { + const req = buildReq(); + const res = buildRes(); + + await logoutController(req, res); + + expect(mockClearCloudFrontCookies).toHaveBeenCalledWith(res); + }); }); }); diff --git a/api/server/services/AuthService.js b/api/server/services/AuthService.js index ef50a365b9ae..79849e3285b6 100644 --- a/api/server/services/AuthService.js +++ b/api/server/services/AuthService.js @@ -11,6 +11,7 @@ const { math, isEnabled, checkEmailConfig, + setCloudFrontCookies, isEmailDomainAllowed, shouldUseSecureCookie, } = require('@librechat/api'); @@ -406,6 +407,9 @@ const setAuthTokens = async (userId, res, _session = null) => { secure: shouldUseSecureCookie(), sameSite: 'strict', }); + + setCloudFrontCookies(res); + return token; } catch (error) { logger.error('[setAuthTokens] Error in setting authentication tokens:', error); @@ -523,6 +527,9 @@ const setOpenIDAuthTokens = (tokenset, req, res, userId, existingRefreshToken) = sameSite: 'strict', }); } + + setCloudFrontCookies(res); + return appAuthToken; } catch (error) { logger.error('[setOpenIDAuthTokens] Error in setting authentication tokens:', error); diff --git a/api/server/services/AuthService.spec.js b/api/server/services/AuthService.spec.js index da78f8d7752e..c2b4f886dc82 100644 --- a/api/server/services/AuthService.spec.js +++ b/api/server/services/AuthService.spec.js @@ -14,6 +14,7 @@ jest.mock('@librechat/api', () => ({ isEmailDomainAllowed: jest.fn(), math: jest.fn((val, fallback) => (val ? Number(val) : fallback)), shouldUseSecureCookie: jest.fn(() => false), + setCloudFrontCookies: jest.fn(() => true), })); jest.mock('~/models', () => ({ findUser: jest.fn(), @@ -35,8 +36,9 @@ jest.mock('~/strategies/validators', () => ({ registerSchema: { parse: jest.fn() jest.mock('~/server/services/Config', () => ({ getAppConfig: jest.fn() })); jest.mock('~/server/utils', () => ({ sendEmail: jest.fn() })); -const { shouldUseSecureCookie } = require('@librechat/api'); -const { setOpenIDAuthTokens } = require('./AuthService'); +const { shouldUseSecureCookie, setCloudFrontCookies } = require('@librechat/api'); +const { createSession, generateToken, generateRefreshToken, getUserById } = require('~/models'); +const { setOpenIDAuthTokens, setAuthTokens } = require('./AuthService'); /** Helper to build a mock Express response */ function mockResponse() { @@ -267,3 +269,67 @@ describe('setOpenIDAuthTokens', () => { }); }); }); + +describe('CloudFront cookie integration', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('setOpenIDAuthTokens', () => { + const validTokenset = { + id_token: 'the-id-token', + access_token: 'the-access-token', + refresh_token: 'the-refresh-token', + }; + + it('calls setCloudFrontCookies with response object', () => { + const req = mockRequest(); + const res = mockResponse(); + + setOpenIDAuthTokens(validTokenset, req, res, 'user-123'); + + expect(setCloudFrontCookies).toHaveBeenCalledWith(res); + }); + + it('succeeds even when setCloudFrontCookies returns false', () => { + setCloudFrontCookies.mockReturnValue(false); + + const req = mockRequest(); + const res = mockResponse(); + + const result = setOpenIDAuthTokens(validTokenset, req, res, 'user-123'); + + expect(result).toBe('the-id-token'); + }); + }); + + describe('setAuthTokens', () => { + beforeEach(() => { + getUserById.mockResolvedValue({ _id: 'user-123' }); + generateToken.mockResolvedValue('mock-access-token'); + generateRefreshToken.mockReturnValue('mock-refresh-token'); + createSession.mockResolvedValue({ + session: { expiration: new Date(Date.now() + 604800000) }, + refreshToken: 'mock-refresh-token', + }); + }); + + it('calls setCloudFrontCookies with response object', async () => { + const res = mockResponse(); + + await setAuthTokens('user-123', res); + + expect(setCloudFrontCookies).toHaveBeenCalledWith(res); + }); + + it('succeeds even when setCloudFrontCookies returns false', async () => { + setCloudFrontCookies.mockReturnValue(false); + + const res = mockResponse(); + + const result = await setAuthTokens('user-123', res); + + expect(result).toBe('mock-access-token'); + }); + }); +}); diff --git a/api/server/services/Files/images/encode.js b/api/server/services/Files/images/encode.js index 93d0aebd4be0..2f075229aa94 100644 --- a/api/server/services/Files/images/encode.js +++ b/api/server/services/Files/images/encode.js @@ -80,7 +80,12 @@ const base64Only = new Set([ EModelEndpoint.bedrock, ]); -const blobStorageSources = new Set([FileSources.azure_blob, FileSources.s3, FileSources.firebase]); +const blobStorageSources = new Set([ + FileSources.azure_blob, + FileSources.s3, + FileSources.firebase, + FileSources.cloudfront, +]); /** * Encodes and formats the given files. diff --git a/librechat.example.yaml b/librechat.example.yaml index 225386e9dd8a..bc061d25155c 100644 --- a/librechat.example.yaml +++ b/librechat.example.yaml @@ -29,15 +29,17 @@ cache: true # CloudFront CDN Configuration (optional) # Use when fileStrategy: "cloudfront" or fileStrategies includes cloudfront # Requires: AWS_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_BUCKET_NAME -# For signed URLs: CLOUDFRONT_KEY_PAIR_ID, CLOUDFRONT_PRIVATE_KEY +# For signed cookies/URLs: CLOUDFRONT_KEY_PAIR_ID, CLOUDFRONT_PRIVATE_KEY # cloudfront: -# domain: "https://d1234abcd.cloudfront.net" -# distributionId: "E1234ABCD" # Required if invalidateOnDelete is true -# invalidateOnDelete: false # Create cache invalidation on file delete -# # NOTE: imageSigning is not yet implemented. All URLs are unsigned regardless of value. -# # Do NOT restrict your CloudFront distribution to signed requests. -# imageSigning: "none" # "none" | "cookies" | "url" -# urlExpiry: 3600 # Signed URL expiry in seconds +# domain: "https://cdn.example.com" # CloudFront domain (CNAME recommended for cookies) +# distributionId: "E1234ABCD" # Required if invalidateOnDelete is true +# invalidateOnDelete: false # Create cache invalidation on file delete +# imageSigning: "none" # "none" (public) | "cookies" (signed cookies) +# # When imageSigning: "cookies", API + CloudFront must share a parent domain: +# # API: api.example.com, CloudFront CNAME: cdn.example.com, cookieDomain: ".example.com" +# cookieDomain: ".example.com" # Required for "cookies" - shared parent domain +# cookieExpiry: 1800 # Cookie lifetime in seconds (max: 604800 / 7 days, default: 1800 / 30 min) +# urlExpiry: 3600 # Reserved for future signed-URL mode (not yet implemented) # Custom interface configuration interface: @@ -127,23 +129,23 @@ interface: # public: false # MCP Servers configuration example # mcpServers: - # Controls user permissions for MCP (Model Context Protocol) server management - # - use: Allow users to use configured MCP servers - # - create: Allow users to create and manage new MCP servers - # - share: Allow users to share MCP servers with other users - # - public: Allow users to share MCP servers publicly (with everyone) - - # Creation / edit MCP server config Dialog config example - # trustCheckbox: - # label: - # en: 'I understand and I want to continue' - # de: 'Ich verstehe und möchte fortfahren' - # de-DE: 'Ich verstehe und möchte fortfahren' # You can narrow translation to regions like (de-DE or de-CH) - # subLabel: - # en: | - # Librechat hasn't reviewed this MCP server. Attackers may attempt to steal your data or trick the model into taking unintended actions, including destroying data. Learn more. - # de: | - # LibreChat hat diesen MCP-Server nicht überprüft. Angreifer könnten versuchen, Ihre Daten zu stehlen oder das Modell zu unbeabsichtigten Aktionen zu verleiten, einschließlich der Zerstörung von Daten. Mehr erfahren. + # Controls user permissions for MCP (Model Context Protocol) server management + # - use: Allow users to use configured MCP servers + # - create: Allow users to create and manage new MCP servers + # - share: Allow users to share MCP servers with other users + # - public: Allow users to share MCP servers publicly (with everyone) + + # Creation / edit MCP server config Dialog config example + # trustCheckbox: + # label: + # en: 'I understand and I want to continue' + # de: 'Ich verstehe und möchte fortfahren' + # de-DE: 'Ich verstehe und möchte fortfahren' # You can narrow translation to regions like (de-DE or de-CH) + # subLabel: + # en: | + # Librechat hasn't reviewed this MCP server. Attackers may attempt to steal your data or trick the model into taking unintended actions, including destroying data. Learn more. + # de: | + # LibreChat hat diesen MCP-Server nicht überprüft. Angreifer könnten versuchen, Ihre Daten zu stehlen oder das Modell zu unbeabsichtigten Aktionen zu verleiten, einschließlich der Zerstörung von Daten. Mehr erfahren. # Temporary chat retention period in hours (default: 720, min: 1, max: 8760) # temporaryChatRetention: 1 diff --git a/packages/api/src/cdn/__tests__/cloudfront-cookies.test.ts b/packages/api/src/cdn/__tests__/cloudfront-cookies.test.ts new file mode 100644 index 000000000000..73f0e258721c --- /dev/null +++ b/packages/api/src/cdn/__tests__/cloudfront-cookies.test.ts @@ -0,0 +1,407 @@ +import type { CloudfrontSignInput } from '@aws-sdk/cloudfront-signer'; + +const mockGetCloudFrontConfig = jest.fn(); +const mockGetSignedCookies = jest.fn(); + +jest.mock('~/cdn/cloudfront', () => ({ + getCloudFrontConfig: () => mockGetCloudFrontConfig(), +})); + +jest.mock('@aws-sdk/cloudfront-signer', () => ({ + getSignedCookies: (params: CloudfrontSignInput) => mockGetSignedCookies(params), +})); + +jest.mock('@librechat/data-schemas', () => ({ + logger: { warn: jest.fn(), error: jest.fn(), info: jest.fn(), debug: jest.fn() }, +})); + +import type { Response } from 'express'; +import { setCloudFrontCookies, clearCloudFrontCookies } from '../cloudfront-cookies'; + +const { logger: mockLogger } = jest.requireMock('@librechat/data-schemas') as { + logger: { warn: jest.Mock; error: jest.Mock; info: jest.Mock; debug: jest.Mock }; +}; + +describe('setCloudFrontCookies', () => { + let mockRes: Partial; + let cookieArgs: Array<[string, string, object]>; + + beforeEach(() => { + jest.clearAllMocks(); + cookieArgs = []; + mockRes = { + cookie: jest.fn((name: string, value: string, options: object) => { + cookieArgs.push([name, value, options]); + return mockRes as Response; + }) as unknown as Response['cookie'], + }; + }); + + it('returns false when CloudFront config is null', () => { + mockGetCloudFrontConfig.mockReturnValue(null); + + const result = setCloudFrontCookies(mockRes as Response); + + expect(result).toBe(false); + expect(mockRes.cookie).not.toHaveBeenCalled(); + }); + + it('returns false when imageSigning is not "cookies"', () => { + mockGetCloudFrontConfig.mockReturnValue({ + domain: 'https://cdn.example.com', + imageSigning: 'none', + cookieDomain: '.example.com', + privateKey: 'test-key', + keyPairId: 'K123', + }); + + const result = setCloudFrontCookies(mockRes as Response); + + expect(result).toBe(false); + expect(mockRes.cookie).not.toHaveBeenCalled(); + }); + + it('returns false when signing keys are missing', () => { + mockGetCloudFrontConfig.mockReturnValue({ + domain: 'https://cdn.example.com', + imageSigning: 'cookies', + cookieDomain: '.example.com', + privateKey: null, + keyPairId: null, + }); + + const result = setCloudFrontCookies(mockRes as Response); + + expect(result).toBe(false); + expect(mockRes.cookie).not.toHaveBeenCalled(); + }); + + it('returns false when cookieDomain is missing', () => { + mockGetCloudFrontConfig.mockReturnValue({ + domain: 'https://cdn.example.com', + imageSigning: 'cookies', + cookieExpiry: 1800, + privateKey: '-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----', + keyPairId: 'K123ABC', + }); + + const result = setCloudFrontCookies(mockRes as Response); + + expect(result).toBe(false); + expect(mockRes.cookie).not.toHaveBeenCalled(); + }); + + it('uses default expiry of 1800s when cookieExpiry is missing from config (Zod default not applied)', () => { + mockGetCloudFrontConfig.mockReturnValue({ + domain: 'https://cdn.example.com', + imageSigning: 'cookies', + // cookieExpiry intentionally absent — simulates raw YAML without Zod defaults + cookieDomain: '.example.com', + privateKey: '-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----', + keyPairId: 'K123ABC', + }); + + mockGetSignedCookies.mockReturnValue({ + 'CloudFront-Policy': 'policy-value', + 'CloudFront-Signature': 'signature-value', + 'CloudFront-Key-Pair-Id': 'K123ABC', + }); + + const result = setCloudFrontCookies(mockRes as Response); + + expect(result).toBe(true); + expect(mockLogger.warn).not.toHaveBeenCalled(); + const [, , options] = cookieArgs[0]; + expect((options as { expires: Date }).expires).toBeInstanceOf(Date); + expect(isNaN((options as { expires: Date }).expires.getTime())).toBe(false); + }); + + it('sets three CloudFront cookies when enabled', () => { + mockGetCloudFrontConfig.mockReturnValue({ + domain: 'https://cdn.example.com', + imageSigning: 'cookies', + cookieExpiry: 1800, + cookieDomain: '.example.com', + privateKey: '-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----', + keyPairId: 'K123ABC', + }); + + mockGetSignedCookies.mockReturnValue({ + 'CloudFront-Policy': 'policy-value', + 'CloudFront-Signature': 'signature-value', + 'CloudFront-Key-Pair-Id': 'K123ABC', + }); + + const result = setCloudFrontCookies(mockRes as Response); + + expect(result).toBe(true); + expect(mockRes.cookie).toHaveBeenCalledTimes(3); + + const cookieNames = cookieArgs.map(([name]) => name); + expect(cookieNames).toContain('CloudFront-Policy'); + expect(cookieNames).toContain('CloudFront-Signature'); + expect(cookieNames).toContain('CloudFront-Key-Pair-Id'); + }); + + it('uses cookieDomain from config with path', () => { + mockGetCloudFrontConfig.mockReturnValue({ + domain: 'https://cdn.example.com', + imageSigning: 'cookies', + cookieExpiry: 1800, + cookieDomain: '.example.com', + privateKey: '-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----', + keyPairId: 'K123ABC', + }); + + mockGetSignedCookies.mockReturnValue({ + 'CloudFront-Policy': 'policy-value', + 'CloudFront-Signature': 'signature-value', + 'CloudFront-Key-Pair-Id': 'K123ABC', + }); + + setCloudFrontCookies(mockRes as Response); + + const [, , options] = cookieArgs[0]; + expect(options).toMatchObject({ + httpOnly: true, + secure: true, + sameSite: 'none', + domain: '.example.com', + path: '/images', + }); + }); + + it('builds correct custom policy for images resource', () => { + mockGetCloudFrontConfig.mockReturnValue({ + domain: 'https://cdn.example.com', + imageSigning: 'cookies', + cookieExpiry: 1800, + cookieDomain: '.example.com', + privateKey: '-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----', + keyPairId: 'K123ABC', + }); + + mockGetSignedCookies.mockReturnValue({ + 'CloudFront-Policy': 'policy-value', + 'CloudFront-Signature': 'signature-value', + 'CloudFront-Key-Pair-Id': 'K123ABC', + }); + + setCloudFrontCookies(mockRes as Response); + + expect(mockGetSignedCookies).toHaveBeenCalledWith( + expect.objectContaining({ + keyPairId: 'K123ABC', + privateKey: expect.stringContaining('BEGIN RSA PRIVATE KEY'), + policy: expect.stringContaining('https://cdn.example.com/images/*'), + }), + ); + }); + + it('handles multiple trailing slashes in domain', () => { + mockGetCloudFrontConfig.mockReturnValue({ + domain: 'https://cdn.example.com///', + imageSigning: 'cookies', + cookieExpiry: 1800, + cookieDomain: '.example.com', + privateKey: '-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----', + keyPairId: 'K123ABC', + }); + + mockGetSignedCookies.mockReturnValue({ + 'CloudFront-Policy': 'policy-value', + 'CloudFront-Signature': 'signature-value', + 'CloudFront-Key-Pair-Id': 'K123ABC', + }); + + setCloudFrontCookies(mockRes as Response); + + expect(mockGetSignedCookies).toHaveBeenCalledWith( + expect.objectContaining({ + policy: expect.stringContaining('https://cdn.example.com/images/*'), + }), + ); + }); + + it('returns false when getSignedCookies returns empty object', () => { + mockGetCloudFrontConfig.mockReturnValue({ + domain: 'https://cdn.example.com', + imageSigning: 'cookies', + cookieExpiry: 1800, + cookieDomain: '.example.com', + privateKey: '-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----', + keyPairId: 'K123ABC', + }); + + mockGetSignedCookies.mockReturnValue({}); + + const result = setCloudFrontCookies(mockRes as Response); + + expect(result).toBe(false); + expect(mockRes.cookie).not.toHaveBeenCalled(); + expect(mockLogger.error).toHaveBeenCalledWith( + expect.stringContaining('Missing expected cookie from AWS SDK'), + ); + }); + + it('returns false when getSignedCookies returns partial result', () => { + mockGetCloudFrontConfig.mockReturnValue({ + domain: 'https://cdn.example.com', + imageSigning: 'cookies', + cookieExpiry: 1800, + cookieDomain: '.example.com', + privateKey: '-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----', + keyPairId: 'K123ABC', + }); + + mockGetSignedCookies.mockReturnValue({ 'CloudFront-Policy': 'policy-value' }); + + const result = setCloudFrontCookies(mockRes as Response); + + expect(result).toBe(false); + expect(mockRes.cookie).not.toHaveBeenCalled(); + }); + + it('returns false and logs error on signing error', () => { + mockGetCloudFrontConfig.mockReturnValue({ + domain: 'https://cdn.example.com', + imageSigning: 'cookies', + cookieExpiry: 1800, + cookieDomain: '.example.com', + privateKey: 'invalid-key', + keyPairId: 'K123ABC', + }); + + const signingError = new Error('Invalid private key'); + mockGetSignedCookies.mockImplementation(() => { + throw signingError; + }); + + const result = setCloudFrontCookies(mockRes as Response); + + expect(result).toBe(false); + expect(mockRes.cookie).not.toHaveBeenCalled(); + expect(mockLogger.error).toHaveBeenCalledWith( + '[setCloudFrontCookies] Failed to generate signed cookies:', + signingError, + ); + }); +}); + +describe('clearCloudFrontCookies', () => { + let mockRes: Partial; + let clearedCookies: Array<[string, object]>; + + beforeEach(() => { + jest.clearAllMocks(); + clearedCookies = []; + mockRes = { + clearCookie: jest.fn((name: string, options: object) => { + clearedCookies.push([name, options]); + return mockRes as Response; + }) as unknown as Response['clearCookie'], + }; + }); + + it('does nothing when config is null', () => { + mockGetCloudFrontConfig.mockReturnValue(null); + + clearCloudFrontCookies(mockRes as Response); + + expect(mockRes.clearCookie).not.toHaveBeenCalled(); + }); + + it('does nothing when imageSigning is not "cookies"', () => { + mockGetCloudFrontConfig.mockReturnValue({ + domain: 'https://cdn.example.com', + imageSigning: 'none', + cookieDomain: '.example.com', + }); + + clearCloudFrontCookies(mockRes as Response); + + expect(mockRes.clearCookie).not.toHaveBeenCalled(); + }); + + it('does nothing when cookieDomain is missing', () => { + mockGetCloudFrontConfig.mockReturnValue({ + domain: 'https://cdn.example.com', + imageSigning: 'cookies', + }); + + clearCloudFrontCookies(mockRes as Response); + + expect(mockRes.clearCookie).not.toHaveBeenCalled(); + }); + + it('clears all three CloudFront cookies with correct domain', () => { + mockGetCloudFrontConfig.mockReturnValue({ + domain: 'https://cdn.example.com', + imageSigning: 'cookies', + cookieDomain: '.example.com', + privateKey: 'test-key', + keyPairId: 'K123', + }); + + clearCloudFrontCookies(mockRes as Response); + + expect(mockRes.clearCookie).toHaveBeenCalledTimes(3); + + const expectedOptions = { + domain: '.example.com', + path: '/images', + httpOnly: true, + secure: true, + sameSite: 'none', + }; + expect(clearedCookies).toContainEqual(['CloudFront-Policy', expectedOptions]); + expect(clearedCookies).toContainEqual(['CloudFront-Signature', expectedOptions]); + expect(clearedCookies).toContainEqual(['CloudFront-Key-Pair-Id', expectedOptions]); + }); + + it('clears cookies with full security attributes matching set path', () => { + mockGetCloudFrontConfig.mockReturnValue({ + domain: 'https://cdn.example.com', + imageSigning: 'cookies', + cookieDomain: '.example.com', + privateKey: 'test-key', + keyPairId: 'K123', + }); + + clearCloudFrontCookies(mockRes as Response); + + expect(mockRes.clearCookie).toHaveBeenCalledTimes(3); + + const expectedOptions = { + domain: '.example.com', + path: '/images', + httpOnly: true, + secure: true, + sameSite: 'none', + }; + expect(clearedCookies).toContainEqual(['CloudFront-Policy', expectedOptions]); + expect(clearedCookies).toContainEqual(['CloudFront-Signature', expectedOptions]); + expect(clearedCookies).toContainEqual(['CloudFront-Key-Pair-Id', expectedOptions]); + }); + + it('logs warning and does not throw when clearing fails', () => { + mockGetCloudFrontConfig.mockReturnValue({ + domain: 'https://cdn.example.com', + imageSigning: 'cookies', + cookieDomain: '.example.com', + privateKey: 'test-key', + keyPairId: 'K123', + }); + + const clearError = new Error('Cookie clear failed'); + mockRes.clearCookie = jest.fn(() => { + throw clearError; + }) as unknown as Response['clearCookie']; + + expect(() => clearCloudFrontCookies(mockRes as Response)).not.toThrow(); + expect(mockLogger.warn).toHaveBeenCalledWith( + '[clearCloudFrontCookies] Failed to clear cookies:', + clearError, + ); + }); +}); diff --git a/packages/api/src/cdn/__tests__/cloudfront.test.ts b/packages/api/src/cdn/__tests__/cloudfront.test.ts index 1a937e42c87b..bcdf202fe6d7 100644 --- a/packages/api/src/cdn/__tests__/cloudfront.test.ts +++ b/packages/api/src/cdn/__tests__/cloudfront.test.ts @@ -20,6 +20,7 @@ function makeConfig(overrides: Partial = {}): Required invalidateOnDelete: false, imageSigning: 'none', urlExpiry: 3600, + cookieExpiry: 1800, ...overrides, }; } @@ -63,14 +64,17 @@ describe('CloudFront CDN module', () => { expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining('without signing keys')); }); - it('returns true and logs with signing keys when env vars are set', async () => { + it('returns true without signing-key warnings when keys are set and imageSigning is "none"', async () => { process.env.CLOUDFRONT_KEY_PAIR_ID = 'K123'; process.env.CLOUDFRONT_PRIVATE_KEY = '-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----'; const { initializeCloudFront } = await load(); expect(initializeCloudFront(makeConfig())).toBe(true); - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('Signing keys are configured but URL signing is not yet active'), + expect(mockLogger.warn).not.toHaveBeenCalledWith( + expect.stringContaining('Signing keys are configured'), + ); + expect(mockLogger.info).not.toHaveBeenCalledWith( + expect.stringContaining('without signing keys'), ); }); @@ -101,12 +105,33 @@ describe('CloudFront CDN module', () => { ); }); - it('warns when imageSigning is not "none"', async () => { + it('returns false and errors when imageSigning is "cookies" but signing keys are missing', async () => { + const { initializeCloudFront } = await load(); + const result = initializeCloudFront(makeConfig({ imageSigning: 'cookies' })); + expect(result).toBe(false); + expect(mockLogger.error).toHaveBeenCalledWith( + expect.stringContaining( + '[initializeCloudFront] imageSigning="cookies" requires CLOUDFRONT_KEY_PAIR_ID', + ), + ); + }); + + it('logs info when imageSigning is "cookies" and signing keys are present', async () => { + process.env.CLOUDFRONT_KEY_PAIR_ID = 'K123'; + process.env.CLOUDFRONT_PRIVATE_KEY = 'my-private-key'; const { initializeCloudFront } = await load(); initializeCloudFront(makeConfig({ imageSigning: 'cookies' })); + expect(mockLogger.info).toHaveBeenCalledWith( + expect.stringContaining('CloudFront cookie signing enabled'), + ); + }); + + it('warns when imageSigning is "url"', async () => { + const { initializeCloudFront } = await load(); + initializeCloudFront(makeConfig({ imageSigning: 'url' })); expect(mockLogger.warn).toHaveBeenCalledWith( expect.stringContaining( - '[initializeCloudFront] imageSigning="cookies" is configured but not yet implemented', + '[initializeCloudFront] imageSigning="url" is configured but not yet implemented', ), ); }); diff --git a/packages/api/src/cdn/cloudfront-cookies.ts b/packages/api/src/cdn/cloudfront-cookies.ts new file mode 100644 index 000000000000..8ed23dc1d185 --- /dev/null +++ b/packages/api/src/cdn/cloudfront-cookies.ts @@ -0,0 +1,109 @@ +import { logger } from '@librechat/data-schemas'; +import { getSignedCookies } from '@aws-sdk/cloudfront-signer'; + +import type { Response } from 'express'; + +import { getCloudFrontConfig } from './cloudfront'; + +const DEFAULT_COOKIE_EXPIRY = 1800; + +const REQUIRED_CF_COOKIES = [ + 'CloudFront-Policy', + 'CloudFront-Signature', + 'CloudFront-Key-Pair-Id', +] as const; + +/** + * Clears CloudFront signed cookies from the response. + * Should be called during logout to revoke CDN access. + */ +export function clearCloudFrontCookies(res: Response): void { + try { + const config = getCloudFrontConfig(); + if (!config?.cookieDomain || config.imageSigning !== 'cookies') { + return; + } + const options = { + domain: config.cookieDomain, + path: '/images', + httpOnly: true, + secure: true, + sameSite: 'none' as const, + }; + res.clearCookie('CloudFront-Policy', options); + res.clearCookie('CloudFront-Signature', options); + res.clearCookie('CloudFront-Key-Pair-Id', options); + } catch (error) { + logger.warn('[clearCloudFrontCookies] Failed to clear cookies:', error); + } +} + +/** + * Sets CloudFront signed cookies on the response for CDN access. + * Returns true if cookies were set, false if CloudFront cookies are not enabled. + */ +export function setCloudFrontCookies(res: Response): boolean { + const config = getCloudFrontConfig(); + if ( + !config || + config.imageSigning !== 'cookies' || + !config.privateKey || + !config.keyPairId || + !config.cookieDomain + ) { + return false; + } + + try { + const cookieExpiry = config.cookieExpiry ?? DEFAULT_COOKIE_EXPIRY; + const expiresAtMs = Date.now() + cookieExpiry * 1000; + const expiresAt = new Date(expiresAtMs); + const expiresAtEpoch = Math.floor(expiresAtMs / 1000); + + const resourceUrl = `${config.domain.replace(/\/+$/, '')}/images/*`; + + const policy = JSON.stringify({ + Statement: [ + { + Resource: resourceUrl, + Condition: { + DateLessThan: { + 'AWS:EpochTime': expiresAtEpoch, + }, + }, + }, + ], + }); + + const signedCookies = getSignedCookies({ + keyPairId: config.keyPairId, + privateKey: config.privateKey, + policy, + }); + + const cookieOptions = { + expires: expiresAt, + httpOnly: true, + secure: true, + sameSite: 'none' as const, + domain: config.cookieDomain, + path: '/images', + }; + + for (const key of REQUIRED_CF_COOKIES) { + if (!signedCookies[key]) { + logger.error(`[setCloudFrontCookies] Missing expected cookie from AWS SDK: ${key}`); + return false; + } + } + + for (const key of REQUIRED_CF_COOKIES) { + res.cookie(key, signedCookies[key], cookieOptions); + } + + return true; + } catch (error) { + logger.error('[setCloudFrontCookies] Failed to generate signed cookies:', error); + return false; + } +} diff --git a/packages/api/src/cdn/cloudfront.ts b/packages/api/src/cdn/cloudfront.ts index b2c8ef31160e..9f1026007bb6 100644 --- a/packages/api/src/cdn/cloudfront.ts +++ b/packages/api/src/cdn/cloudfront.ts @@ -29,20 +29,26 @@ export function initializeCloudFront(config: CloudFrontConfig): boolean { const keyPairId = process.env.CLOUDFRONT_KEY_PAIR_ID ?? null; const privateKey = process.env.CLOUDFRONT_PRIVATE_KEY ?? null; + if (config.imageSigning === 'cookies' && (!keyPairId || !privateKey)) { + logger.error( + '[initializeCloudFront] imageSigning="cookies" requires CLOUDFRONT_KEY_PAIR_ID and CLOUDFRONT_PRIVATE_KEY env vars.', + ); + return false; + } + cloudFrontConfig = { ...config, privateKey, keyPairId }; - if (config.imageSigning !== 'none') { + if (config.imageSigning === 'cookies') { + logger.info( + '[initializeCloudFront] CloudFront cookie signing enabled. Cookies will be set during auth.', + ); + } else if (config.imageSigning === 'url') { logger.warn( - `[initializeCloudFront] imageSigning="${config.imageSigning}" is configured but not yet implemented. All URLs will be unsigned. Do NOT restrict your CloudFront distribution to signed requests.`, + '[initializeCloudFront] imageSigning="url" is configured but not yet implemented for images.', ); } - if (keyPairId && privateKey) { - logger.warn( - '[initializeCloudFront] Signing keys are configured but URL signing is not yet active. ' + - 'All files will be served via unsigned URLs. Signing will be enabled in a follow-up release.', - ); - } else { + if (!keyPairId || !privateKey) { logger.info( '[initializeCloudFront] CloudFront initialized without signing keys (public OAC only).', ); diff --git a/packages/api/src/cdn/index.ts b/packages/api/src/cdn/index.ts index 32d474553fa4..69febb17d987 100644 --- a/packages/api/src/cdn/index.ts +++ b/packages/api/src/cdn/index.ts @@ -1,4 +1,5 @@ export * from './azure'; export * from './cloudfront'; +export * from './cloudfront-cookies'; export * from './firebase'; export * from './s3'; diff --git a/packages/data-provider/src/__tests__/cloudfront-config.test.ts b/packages/data-provider/src/__tests__/cloudfront-config.test.ts new file mode 100644 index 000000000000..99d7b3c20022 --- /dev/null +++ b/packages/data-provider/src/__tests__/cloudfront-config.test.ts @@ -0,0 +1,29 @@ +import { cloudfrontConfigSchema } from '../config'; + +describe('cloudfrontConfigSchema cookieDomain validation', () => { + it('accepts cookieDomain starting with dot', () => { + const result = cloudfrontConfigSchema.safeParse({ + domain: 'https://cdn.example.com', + cookieDomain: '.example.com', + }); + expect(result.success).toBe(true); + }); + + it('rejects cookieDomain without leading dot', () => { + const result = cloudfrontConfigSchema.safeParse({ + domain: 'https://cdn.example.com', + cookieDomain: 'example.com', + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toContain('must start with a dot'); + } + }); + + it('allows omitting cookieDomain', () => { + const result = cloudfrontConfigSchema.safeParse({ + domain: 'https://cdn.example.com', + }); + expect(result.success).toBe(true); + }); +}); diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 8c820e2a20c9..7bb2cb1fccb9 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -81,11 +81,24 @@ export const cloudfrontConfigSchema = z invalidateOnDelete: z.boolean().default(false), imageSigning: cloudfrontSigningSchema.default('none'), urlExpiry: z.number().positive().default(3600), + cookieExpiry: z.number().positive().max(604800).default(1800), + cookieDomain: z + .string() + .min(1) + .refine((d) => d.startsWith('.'), { + message: 'cookieDomain must start with a dot (e.g., ".example.com") to apply to subdomains', + }) + .optional(), }) .refine((data) => !data.invalidateOnDelete || !!data.distributionId, { message: 'distributionId is required when invalidateOnDelete is true', path: ['distributionId'], }) + .refine((data) => data.imageSigning !== 'cookies' || !!data.cookieDomain, { + message: + 'cookieDomain is required when imageSigning is "cookies" (e.g., ".example.com" for API at api.example.com and CDN at cdn.example.com)', + path: ['cookieDomain'], + }) .optional(); export type CloudFrontConfig = z.infer;