🌩️ feat: CloudFront CDN File Strategy (#12193)

* 🌩️ feat: CloudFront CDN File Strategy + signed cookies

Squashed from PR #12193:
- feat(storage): add CloudFront CDN file strategy
- feat(auth): add CloudFront signed cookie support

Note: package.json/package-lock.json dependency additions are intentionally
omitted from this commit and will be re-added via `npm install` after rebase
to avoid lock-file merge conflicts. The two new peer deps that need to be
re-installed are:
  - @aws-sdk/client-cloudfront@^3.1032.0
  - @aws-sdk/cloudfront-signer@^3.1012.0

Also fixes 4 missing destructured names in AuthService.spec.js
(getUserById, generateToken, generateRefreshToken, createSession) that
were referenced in tests but not imported from the mocked '~/models'.

* 📦 chore: install CloudFront SDK deps for PR #12193

Adds the two AWS CloudFront packages required by the rebased
CloudFront CDN strategy:
  - @aws-sdk/client-cloudfront
  - @aws-sdk/cloudfront-signer

Following the @aws-sdk/client-s3 pattern:
  - api/package.json: regular dependency (runtime resolution)
  - packages/api/package.json: peerDependency

Generated by `npm install` against the freshly rebased lock file
to avoid the merge conflicts that came from the original PR's
lock-file edits being made against an older base of dev.

* 🐛 fix: CI failures + review findings on CloudFront PR #12193

CI fixes
- Rename packages/data-provider/src/__tests__/cloudfront-config.test.ts
  → src/cloudfront-config.spec.ts. Jest's default testMatch picks up
  __tests__/ directories even inside dist/, so the compiled .d.ts shell
  was being executed as an empty test suite. Moving to .spec.ts (matching
  the rest of the package) avoids the dist/ pickup.
- Add cookieExpiry: 1800 to CloudFront crud.test makeConfig: the schema
  applies a default so CloudFrontFullConfig requires it.

Review findings addressed
- #1 (Codex + comprehensive): Normalize CloudFront domain with /\/+$/
  regex (and key with /^\/+/ regex) in buildCloudFrontUrl, matching the
  cookie code so resource policy and file URLs stay aligned even when
  the configured domain has multiple trailing slashes. Added tests.
- #2: Move DEFAULT_BASE_PATH out of s3Config into shared
  packages/api/src/storage/constants.ts. ImageService no longer imports
  S3-specific config.
- #3: getCloudFrontConfig() returns Readonly<CloudFrontFullConfig> | null
  to discourage mutation of the cached signing config.
- #4: Add cross-field refinement tests for cloudfrontConfigSchema
  (invalidateOnDelete-without-distributionId,
  imageSigning="cookies"-without-cookieDomain).
- #6: Revert unrelated MCP comment re-indentation in
  librechat.example.yaml.
- #7: Add azure_blob to the strategy list comment.

Skipped
- #5 (extractKeyFromS3Url with CloudFront URLs): existing
  deleteFileFromCloudFront tests already cover the path-equivalence
  assumption; renaming the helper is real refactor work beyond this
  PR's scope.
- #8, #9 (NIT, low confidence): leaving for author judgement.

* 🧹 chore: drop dead DEFAULT_BASE_PATH from s3Config test mock

After moving DEFAULT_BASE_PATH to ~/storage/constants, crud.ts no longer
reads it from s3Config — so the entry in the s3Config jest mock was
misleading dead config. The tests still pass because the unmocked real
constants module provides the value.

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
Atef Bellaaj 2026-05-05 19:21:05 +02:00 committed by GitHub
parent 4583d5a926
commit 187ab787da
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 2364 additions and 294 deletions

View file

@ -11,6 +11,7 @@ const {
math,
isEnabled,
checkEmailConfig,
setCloudFrontCookies,
isEmailDomainAllowed,
shouldUseSecureCookie,
resolveAppConfigForUser,
@ -440,6 +441,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);
@ -557,6 +561,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);

View file

@ -15,6 +15,7 @@ jest.mock('@librechat/api', () => ({
math: jest.fn((val, fallback) => (val ? Number(val) : fallback)),
shouldUseSecureCookie: jest.fn(() => false),
resolveAppConfigForUser: jest.fn(async (_getAppConfig, _user) => ({})),
setCloudFrontCookies: jest.fn(() => true),
}));
jest.mock('~/models', () => ({
findUser: jest.fn(),
@ -40,10 +41,17 @@ const {
shouldUseSecureCookie,
isEmailDomainAllowed,
resolveAppConfigForUser,
setCloudFrontCookies,
} = require('@librechat/api');
const { findUser } = require('~/models');
const {
findUser,
getUserById,
generateToken,
generateRefreshToken,
createSession,
} = require('~/models');
const { getAppConfig } = require('~/server/services/Config');
const { setOpenIDAuthTokens, requestPasswordReset } = require('./AuthService');
const { setOpenIDAuthTokens, requestPasswordReset, setAuthTokens } = require('./AuthService');
/** Helper to build a mock Express response */
function mockResponse() {
@ -339,3 +347,67 @@ describe('requestPasswordReset', () => {
expect(result.message).toContain('If an account with that email exists');
});
});
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');
});
});
});

View file

@ -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.

View file

@ -2,14 +2,20 @@ const { FileSources } = require('librechat-data-provider');
const {
getS3URL,
saveURLToS3,
ImageService,
parseDocument,
uploadFileToS3,
S3ImageService,
saveBufferToS3,
getS3FileStream,
deleteFileFromS3,
getCloudFrontURL,
uploadMistralOCR,
saveURLToCloudFront,
uploadAzureMistralOCR,
uploadFileToCloudFront,
saveBufferToCloudFront,
getCloudFrontFileStream,
deleteFileFromCloudFront,
uploadGoogleVertexMistralOCR,
} = require('@librechat/api');
const {
@ -37,15 +43,21 @@ const {
const { resizeImageBuffer } = require('./images/resize');
const { updateUser, updateFile } = require('~/models');
const s3ImageService = new S3ImageService({
const imageServiceDeps = {
resizeImageBuffer,
updateUser,
updateFile,
});
};
const uploadImageToS3 = (params) => s3ImageService.uploadImageToS3(params);
const s3ImageService = new ImageService(saveBufferToS3, imageServiceDeps);
const uploadImageToS3 = (params) => s3ImageService.uploadImage(params);
const prepareImageURLS3 = (_req, file) => s3ImageService.prepareImageURL(file);
const processS3Avatar = (params) => s3ImageService.processAvatar(params);
const cloudFrontImageService = new ImageService(saveBufferToCloudFront, imageServiceDeps);
const uploadImageToCloudFront = (params) => cloudFrontImageService.uploadImage(params);
const prepareCloudFrontImageURL = (_req, file) => cloudFrontImageService.prepareImageURL(file);
const processCloudFrontAvatar = (params) => cloudFrontImageService.processAvatar(params);
const {
saveBufferToAzure,
saveURLToAzure,
@ -109,6 +121,22 @@ const s3Strategy = () => ({
getDownloadStream: getS3FileStream,
});
/**
* CloudFront CDN Strategy Functions
* Uses S3 for storage, CloudFront for URL delivery
*/
const cloudfrontStrategy = () => ({
handleFileUpload: uploadFileToCloudFront,
saveURL: saveURLToCloudFront,
getFileURL: getCloudFrontURL,
deleteFile: deleteFileFromCloudFront,
saveBuffer: saveBufferToCloudFront,
prepareImagePayload: prepareCloudFrontImageURL,
processAvatar: processCloudFrontAvatar,
handleImageUpload: uploadImageToCloudFront,
getDownloadStream: getCloudFrontFileStream,
});
/**
* Azure Blob Storage Strategy Functions
*
@ -291,6 +319,8 @@ const getStrategyFunctions = (fileSource) => {
return vectorStrategy();
} else if (fileSource === FileSources.s3) {
return s3Strategy();
} else if (fileSource === FileSources.cloudfront) {
return cloudfrontStrategy();
} else if (fileSource === FileSources.execute_code) {
return codeOutputStrategy();
} else if (fileSource === FileSources.mistral_ocr) {