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;