Skip to content

Commit 85ab1b1

Browse files
committed
🔐 feat(auth): add CloudFront signed cookie support
- 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.
1 parent 4d0b59c commit 85ab1b1

File tree

13 files changed

+727
-43
lines changed

13 files changed

+727
-43
lines changed

api/server/controllers/auth/LogoutController.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
const cookies = require('cookie');
2-
const { isEnabled } = require('@librechat/api');
2+
const { isEnabled, clearCloudFrontCookies } = require('@librechat/api');
33
const { logger } = require('@librechat/data-schemas');
44
const { logoutUser } = require('~/server/services/AuthService');
55
const { getOpenIdConfig } = require('~/strategies');
@@ -28,6 +28,7 @@ const logoutController = async (req, res) => {
2828
res.clearCookie('openid_id_token');
2929
res.clearCookie('openid_user_id');
3030
res.clearCookie('token_provider');
31+
clearCloudFrontCookies(res);
3132
const response = { message };
3233
if (
3334
isOpenIdUser &&

api/server/controllers/auth/LogoutController.spec.js

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,13 @@ const mockLogoutUser = jest.fn();
44
const mockLogger = { warn: jest.fn(), error: jest.fn() };
55
const mockIsEnabled = jest.fn();
66
const mockGetOpenIdConfig = jest.fn();
7+
const mockClearCloudFrontCookies = jest.fn();
78

89
jest.mock('cookie');
9-
jest.mock('@librechat/api', () => ({ isEnabled: (...args) => mockIsEnabled(...args) }));
10+
jest.mock('@librechat/api', () => ({
11+
isEnabled: (...args) => mockIsEnabled(...args),
12+
clearCloudFrontCookies: (...args) => mockClearCloudFrontCookies(...args),
13+
}));
1014
jest.mock('@librechat/data-schemas', () => ({ logger: mockLogger }));
1115
jest.mock('~/server/services/AuthService', () => ({
1216
logoutUser: (...args) => mockLogoutUser(...args),
@@ -255,5 +259,14 @@ describe('LogoutController', () => {
255259
expect(res.clearCookie).toHaveBeenCalledWith('openid_user_id');
256260
expect(res.clearCookie).toHaveBeenCalledWith('token_provider');
257261
});
262+
263+
it('calls clearCloudFrontCookies on successful logout', async () => {
264+
const req = buildReq();
265+
const res = buildRes();
266+
267+
await logoutController(req, res);
268+
269+
expect(mockClearCloudFrontCookies).toHaveBeenCalledWith(res);
270+
});
258271
});
259272
});

api/server/services/AuthService.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ const {
1111
math,
1212
isEnabled,
1313
checkEmailConfig,
14+
setCloudFrontCookies,
1415
isEmailDomainAllowed,
1516
shouldUseSecureCookie,
1617
} = require('@librechat/api');
@@ -406,6 +407,9 @@ const setAuthTokens = async (userId, res, _session = null) => {
406407
secure: shouldUseSecureCookie(),
407408
sameSite: 'strict',
408409
});
410+
411+
setCloudFrontCookies(res);
412+
409413
return token;
410414
} catch (error) {
411415
logger.error('[setAuthTokens] Error in setting authentication tokens:', error);
@@ -523,6 +527,9 @@ const setOpenIDAuthTokens = (tokenset, req, res, userId, existingRefreshToken) =
523527
sameSite: 'strict',
524528
});
525529
}
530+
531+
setCloudFrontCookies(res);
532+
526533
return appAuthToken;
527534
} catch (error) {
528535
logger.error('[setOpenIDAuthTokens] Error in setting authentication tokens:', error);

api/server/services/AuthService.spec.js

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ jest.mock('@librechat/api', () => ({
1414
isEmailDomainAllowed: jest.fn(),
1515
math: jest.fn((val, fallback) => (val ? Number(val) : fallback)),
1616
shouldUseSecureCookie: jest.fn(() => false),
17+
setCloudFrontCookies: jest.fn(() => true),
1718
}));
1819
jest.mock('~/models', () => ({
1920
findUser: jest.fn(),
@@ -35,8 +36,9 @@ jest.mock('~/strategies/validators', () => ({ registerSchema: { parse: jest.fn()
3536
jest.mock('~/server/services/Config', () => ({ getAppConfig: jest.fn() }));
3637
jest.mock('~/server/utils', () => ({ sendEmail: jest.fn() }));
3738

38-
const { shouldUseSecureCookie } = require('@librechat/api');
39-
const { setOpenIDAuthTokens } = require('./AuthService');
39+
const { shouldUseSecureCookie, setCloudFrontCookies } = require('@librechat/api');
40+
const { createSession, generateToken, generateRefreshToken, getUserById } = require('~/models');
41+
const { setOpenIDAuthTokens, setAuthTokens } = require('./AuthService');
4042

4143
/** Helper to build a mock Express response */
4244
function mockResponse() {
@@ -267,3 +269,67 @@ describe('setOpenIDAuthTokens', () => {
267269
});
268270
});
269271
});
272+
273+
describe('CloudFront cookie integration', () => {
274+
beforeEach(() => {
275+
jest.clearAllMocks();
276+
});
277+
278+
describe('setOpenIDAuthTokens', () => {
279+
const validTokenset = {
280+
id_token: 'the-id-token',
281+
access_token: 'the-access-token',
282+
refresh_token: 'the-refresh-token',
283+
};
284+
285+
it('calls setCloudFrontCookies with response object', () => {
286+
const req = mockRequest();
287+
const res = mockResponse();
288+
289+
setOpenIDAuthTokens(validTokenset, req, res, 'user-123');
290+
291+
expect(setCloudFrontCookies).toHaveBeenCalledWith(res);
292+
});
293+
294+
it('succeeds even when setCloudFrontCookies returns false', () => {
295+
setCloudFrontCookies.mockReturnValue(false);
296+
297+
const req = mockRequest();
298+
const res = mockResponse();
299+
300+
const result = setOpenIDAuthTokens(validTokenset, req, res, 'user-123');
301+
302+
expect(result).toBe('the-id-token');
303+
});
304+
});
305+
306+
describe('setAuthTokens', () => {
307+
beforeEach(() => {
308+
getUserById.mockResolvedValue({ _id: 'user-123' });
309+
generateToken.mockResolvedValue('mock-access-token');
310+
generateRefreshToken.mockReturnValue('mock-refresh-token');
311+
createSession.mockResolvedValue({
312+
session: { expiration: new Date(Date.now() + 604800000) },
313+
refreshToken: 'mock-refresh-token',
314+
});
315+
});
316+
317+
it('calls setCloudFrontCookies with response object', async () => {
318+
const res = mockResponse();
319+
320+
await setAuthTokens('user-123', res);
321+
322+
expect(setCloudFrontCookies).toHaveBeenCalledWith(res);
323+
});
324+
325+
it('succeeds even when setCloudFrontCookies returns false', async () => {
326+
setCloudFrontCookies.mockReturnValue(false);
327+
328+
const res = mockResponse();
329+
330+
const result = await setAuthTokens('user-123', res);
331+
332+
expect(result).toBe('mock-access-token');
333+
});
334+
});
335+
});

api/server/services/Files/images/encode.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,12 @@ const base64Only = new Set([
8080
EModelEndpoint.bedrock,
8181
]);
8282

83-
const blobStorageSources = new Set([FileSources.azure_blob, FileSources.s3, FileSources.firebase]);
83+
const blobStorageSources = new Set([
84+
FileSources.azure_blob,
85+
FileSources.s3,
86+
FileSources.firebase,
87+
FileSources.cloudfront,
88+
]);
8489

8590
/**
8691
* Encodes and formats the given files.

librechat.example.yaml

Lines changed: 27 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,17 @@ cache: true
2929
# CloudFront CDN Configuration (optional)
3030
# Use when fileStrategy: "cloudfront" or fileStrategies includes cloudfront
3131
# Requires: AWS_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_BUCKET_NAME
32-
# For signed URLs: CLOUDFRONT_KEY_PAIR_ID, CLOUDFRONT_PRIVATE_KEY
32+
# For signed cookies/URLs: CLOUDFRONT_KEY_PAIR_ID, CLOUDFRONT_PRIVATE_KEY
3333
# cloudfront:
34-
# domain: "https://d1234abcd.cloudfront.net"
35-
# distributionId: "E1234ABCD" # Required if invalidateOnDelete is true
36-
# invalidateOnDelete: false # Create cache invalidation on file delete
37-
# # NOTE: imageSigning is not yet implemented. All URLs are unsigned regardless of value.
38-
# # Do NOT restrict your CloudFront distribution to signed requests.
39-
# imageSigning: "none" # "none" | "cookies" | "url"
40-
# urlExpiry: 3600 # Signed URL expiry in seconds
34+
# domain: "https://cdn.example.com" # CloudFront domain (CNAME recommended for cookies)
35+
# distributionId: "E1234ABCD" # Required if invalidateOnDelete is true
36+
# invalidateOnDelete: false # Create cache invalidation on file delete
37+
# imageSigning: "none" # "none" (public) | "cookies" (signed cookies)
38+
# # When imageSigning: "cookies", API + CloudFront must share a parent domain:
39+
# # API: api.example.com, CloudFront CNAME: cdn.example.com, cookieDomain: ".example.com"
40+
# cookieDomain: ".example.com" # Required for "cookies" - shared parent domain
41+
# cookieExpiry: 1800 # Cookie lifetime in seconds (max: 604800 / 7 days, default: 1800 / 30 min)
42+
# urlExpiry: 3600 # Reserved for future signed-URL mode (not yet implemented)
4143

4244
# Custom interface configuration
4345
interface:
@@ -127,23 +129,23 @@ interface:
127129
# public: false
128130
# MCP Servers configuration example
129131
# mcpServers:
130-
# Controls user permissions for MCP (Model Context Protocol) server management
131-
# - use: Allow users to use configured MCP servers
132-
# - create: Allow users to create and manage new MCP servers
133-
# - share: Allow users to share MCP servers with other users
134-
# - public: Allow users to share MCP servers publicly (with everyone)
135-
136-
# Creation / edit MCP server config Dialog config example
137-
# trustCheckbox:
138-
# label:
139-
# en: 'I understand and I want to continue'
140-
# de: 'Ich verstehe und möchte fortfahren'
141-
# de-DE: 'Ich verstehe und möchte fortfahren' # You can narrow translation to regions like (de-DE or de-CH)
142-
# subLabel:
143-
# en: |
144-
# 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. <a href="https://google.de" target="_blank"><strong>Learn more.</strong></a>
145-
# de: |
146-
# 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. <a href="https://google.de" target="_blank"><strong>Mehr erfahren.</strong></a>
132+
# Controls user permissions for MCP (Model Context Protocol) server management
133+
# - use: Allow users to use configured MCP servers
134+
# - create: Allow users to create and manage new MCP servers
135+
# - share: Allow users to share MCP servers with other users
136+
# - public: Allow users to share MCP servers publicly (with everyone)
137+
138+
# Creation / edit MCP server config Dialog config example
139+
# trustCheckbox:
140+
# label:
141+
# en: 'I understand and I want to continue'
142+
# de: 'Ich verstehe und möchte fortfahren'
143+
# de-DE: 'Ich verstehe und möchte fortfahren' # You can narrow translation to regions like (de-DE or de-CH)
144+
# subLabel:
145+
# en: |
146+
# 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. <a href="https://google.de" target="_blank"><strong>Learn more.</strong></a>
147+
# de: |
148+
# 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. <a href="https://google.de" target="_blank"><strong>Mehr erfahren.</strong></a>
147149

148150
# Temporary chat retention period in hours (default: 720, min: 1, max: 8760)
149151
# temporaryChatRetention: 1

0 commit comments

Comments
 (0)