Skip to content

Commit 2294f32

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". - Add cookieDomain and cookieExpiry config options with validation requiring shared parent domain.
1 parent 4d0b59c commit 2294f32

File tree

9 files changed

+437
-23
lines changed

9 files changed

+437
-23
lines changed

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+
});

librechat.example.yaml

Lines changed: 10 additions & 8 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 (default: 30 min)
42+
# urlExpiry: 3600 # Signed URL expiry for documents (default: 1 hour)
4143

4244
# Custom interface configuration
4345
interface:
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
const mockGetCloudFrontConfig = jest.fn();
2+
const mockGetSignedCookies = jest.fn();
3+
4+
jest.mock('~/cdn/cloudfront', () => ({
5+
getCloudFrontConfig: () => mockGetCloudFrontConfig(),
6+
}));
7+
8+
jest.mock('@aws-sdk/cloudfront-signer', () => ({
9+
getSignedCookies: (params: unknown) => mockGetSignedCookies(params),
10+
}));
11+
12+
import type { Response } from 'express';
13+
import { isCloudFrontCookiesEnabled, setCloudFrontCookies } from '../cloudfront-cookies';
14+
15+
describe('isCloudFrontCookiesEnabled', () => {
16+
beforeEach(() => {
17+
jest.clearAllMocks();
18+
});
19+
20+
it('returns false when CloudFront config is null', () => {
21+
mockGetCloudFrontConfig.mockReturnValue(null);
22+
23+
const result = isCloudFrontCookiesEnabled();
24+
25+
expect(result).toBe(false);
26+
});
27+
28+
it('returns false when imageSigning is not "cookies"', () => {
29+
mockGetCloudFrontConfig.mockReturnValue({
30+
domain: 'https://cdn.example.com',
31+
imageSigning: 'none',
32+
cookieDomain: '.example.com',
33+
privateKey: 'test-key',
34+
keyPairId: 'K123',
35+
});
36+
37+
const result = isCloudFrontCookiesEnabled();
38+
39+
expect(result).toBe(false);
40+
});
41+
42+
it('returns false when signing keys are missing', () => {
43+
mockGetCloudFrontConfig.mockReturnValue({
44+
domain: 'https://cdn.example.com',
45+
imageSigning: 'cookies',
46+
cookieDomain: '.example.com',
47+
privateKey: null,
48+
keyPairId: null,
49+
});
50+
51+
const result = isCloudFrontCookiesEnabled();
52+
53+
expect(result).toBe(false);
54+
});
55+
56+
it('returns false when cookieDomain is missing', () => {
57+
mockGetCloudFrontConfig.mockReturnValue({
58+
domain: 'https://cdn.example.com',
59+
imageSigning: 'cookies',
60+
privateKey: 'test-key',
61+
keyPairId: 'K123',
62+
});
63+
64+
const result = isCloudFrontCookiesEnabled();
65+
66+
expect(result).toBe(false);
67+
});
68+
69+
it('returns true when all conditions are met', () => {
70+
mockGetCloudFrontConfig.mockReturnValue({
71+
domain: 'https://cdn.example.com',
72+
imageSigning: 'cookies',
73+
cookieDomain: '.example.com',
74+
privateKey: 'test-key',
75+
keyPairId: 'K123',
76+
});
77+
78+
const result = isCloudFrontCookiesEnabled();
79+
80+
expect(result).toBe(true);
81+
});
82+
});
83+
84+
describe('setCloudFrontCookies', () => {
85+
let mockRes: Partial<Response>;
86+
let cookieArgs: Array<[string, string, object]>;
87+
88+
beforeEach(() => {
89+
jest.clearAllMocks();
90+
cookieArgs = [];
91+
mockRes = {
92+
cookie: jest.fn((name: string, value: string, options: object) => {
93+
cookieArgs.push([name, value, options]);
94+
return mockRes as Response;
95+
}) as unknown as Response['cookie'],
96+
};
97+
});
98+
99+
it('returns false when CloudFront config is null', () => {
100+
mockGetCloudFrontConfig.mockReturnValue(null);
101+
102+
const result = setCloudFrontCookies(mockRes as Response);
103+
104+
expect(result).toBe(false);
105+
expect(mockRes.cookie).not.toHaveBeenCalled();
106+
});
107+
108+
it('sets three CloudFront cookies when enabled', () => {
109+
mockGetCloudFrontConfig.mockReturnValue({
110+
domain: 'https://cdn.example.com',
111+
imageSigning: 'cookies',
112+
cookieExpiry: 1800,
113+
cookieDomain: '.example.com',
114+
privateKey: '-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----',
115+
keyPairId: 'K123ABC',
116+
});
117+
118+
mockGetSignedCookies.mockReturnValue({
119+
'CloudFront-Policy': 'policy-value',
120+
'CloudFront-Signature': 'signature-value',
121+
'CloudFront-Key-Pair-Id': 'K123ABC',
122+
});
123+
124+
const result = setCloudFrontCookies(mockRes as Response);
125+
126+
expect(result).toBe(true);
127+
expect(mockRes.cookie).toHaveBeenCalledTimes(3);
128+
129+
const cookieNames = cookieArgs.map(([name]) => name);
130+
expect(cookieNames).toContain('CloudFront-Policy');
131+
expect(cookieNames).toContain('CloudFront-Signature');
132+
expect(cookieNames).toContain('CloudFront-Key-Pair-Id');
133+
});
134+
135+
it('uses cookieDomain from config', () => {
136+
mockGetCloudFrontConfig.mockReturnValue({
137+
domain: 'https://cdn.example.com',
138+
imageSigning: 'cookies',
139+
cookieExpiry: 1800,
140+
cookieDomain: '.example.com',
141+
privateKey: '-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----',
142+
keyPairId: 'K123ABC',
143+
});
144+
145+
mockGetSignedCookies.mockReturnValue({
146+
'CloudFront-Policy': 'policy-value',
147+
'CloudFront-Signature': 'signature-value',
148+
'CloudFront-Key-Pair-Id': 'K123ABC',
149+
});
150+
151+
setCloudFrontCookies(mockRes as Response);
152+
153+
const [, , options] = cookieArgs[0];
154+
expect(options).toMatchObject({
155+
httpOnly: true,
156+
secure: true,
157+
sameSite: 'none',
158+
domain: '.example.com',
159+
});
160+
});
161+
162+
it('builds correct custom policy for wildcard resource', () => {
163+
mockGetCloudFrontConfig.mockReturnValue({
164+
domain: 'https://cdn.example.com',
165+
imageSigning: 'cookies',
166+
cookieExpiry: 1800,
167+
cookieDomain: '.example.com',
168+
privateKey: '-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----',
169+
keyPairId: 'K123ABC',
170+
});
171+
172+
mockGetSignedCookies.mockReturnValue({
173+
'CloudFront-Policy': 'policy-value',
174+
'CloudFront-Signature': 'signature-value',
175+
'CloudFront-Key-Pair-Id': 'K123ABC',
176+
});
177+
178+
setCloudFrontCookies(mockRes as Response);
179+
180+
expect(mockGetSignedCookies).toHaveBeenCalledWith(
181+
expect.objectContaining({
182+
keyPairId: 'K123ABC',
183+
privateKey: expect.stringContaining('BEGIN RSA PRIVATE KEY'),
184+
policy: expect.stringContaining('https://cdn.example.com/*'),
185+
}),
186+
);
187+
});
188+
189+
it('returns false when cookieDomain is missing', () => {
190+
mockGetCloudFrontConfig.mockReturnValue({
191+
domain: 'https://cdn.example.com',
192+
imageSigning: 'cookies',
193+
cookieExpiry: 1800,
194+
privateKey: '-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----',
195+
keyPairId: 'K123ABC',
196+
// cookieDomain is missing
197+
});
198+
199+
const result = setCloudFrontCookies(mockRes as Response);
200+
201+
expect(result).toBe(false);
202+
expect(mockRes.cookie).not.toHaveBeenCalled();
203+
});
204+
205+
it('returns false and logs warning on signing error', () => {
206+
mockGetCloudFrontConfig.mockReturnValue({
207+
domain: 'https://cdn.example.com',
208+
imageSigning: 'cookies',
209+
cookieExpiry: 1800,
210+
cookieDomain: '.example.com',
211+
privateKey: 'invalid-key',
212+
keyPairId: 'K123ABC',
213+
});
214+
215+
mockGetSignedCookies.mockImplementation(() => {
216+
throw new Error('Invalid private key');
217+
});
218+
219+
const result = setCloudFrontCookies(mockRes as Response);
220+
221+
expect(result).toBe(false);
222+
expect(mockRes.cookie).not.toHaveBeenCalled();
223+
});
224+
});

0 commit comments

Comments
 (0)