mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-27 04:07:05 +00:00
🍪 fix: Refresh CloudFront Cookies On Auth Refresh (#13083)
* fix: Refresh CloudFront Cookies On Auth Refresh * fix: Exclude Federated Tokens From Refresh Lookup
This commit is contained in:
parent
929082387f
commit
17a08224e1
4 changed files with 465 additions and 43 deletions
|
|
@ -12,6 +12,7 @@ const {
|
|||
isEnabled,
|
||||
checkEmailConfig,
|
||||
setCloudFrontCookies,
|
||||
getCloudFrontConfig,
|
||||
parseCloudFrontCookieScope,
|
||||
CLOUDFRONT_SCOPE_COOKIE,
|
||||
isEmailDomainAllowed,
|
||||
|
|
@ -411,6 +412,85 @@ const resetPassword = async (userId, token, password) => {
|
|||
const getPreviousCloudFrontScope = (req) =>
|
||||
parseCloudFrontCookieScope(req?.cookies?.[CLOUDFRONT_SCOPE_COOKIE]);
|
||||
|
||||
const normalizeCloudFrontScopeValue = (value) => {
|
||||
if (value == null) {
|
||||
return value;
|
||||
}
|
||||
return value.toString?.() ?? value;
|
||||
};
|
||||
|
||||
const getCloudFrontScopeValue = (optionsValue, userValue, requestValue) =>
|
||||
normalizeCloudFrontScopeValue(optionsValue ?? userValue ?? requestValue);
|
||||
|
||||
const getCloudFrontAuthCookieSkipReason = (scope) => {
|
||||
const config = getCloudFrontConfig();
|
||||
if (!config || config.imageSigning !== 'cookies' || !config.privateKey || !config.keyPairId) {
|
||||
return 'cloudfront_disabled';
|
||||
}
|
||||
if (!config.cookieDomain) {
|
||||
return 'missing_cookie_domain';
|
||||
}
|
||||
if (!scope.userId) {
|
||||
return 'missing_user_id';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Refreshes CloudFront signed cookies for authenticated image/avatar access.
|
||||
* @param {ServerRequest | null} req
|
||||
* @param {ServerResponse} res
|
||||
* @param {Partial<IUser> | null} user
|
||||
* @param {import('@librechat/api').CloudFrontCookieScope & { orgId?: string }} [options={}]
|
||||
* @returns {boolean}
|
||||
*/
|
||||
const setCloudFrontAuthCookies = (req, res, user, options = {}) => {
|
||||
const storageRegion = getCloudFrontScopeValue(
|
||||
options.storageRegion,
|
||||
user?.storageRegion,
|
||||
req?.user?.storageRegion,
|
||||
);
|
||||
const scope = {
|
||||
userId: getCloudFrontScopeValue(
|
||||
options.userId,
|
||||
user?._id ?? user?.id,
|
||||
req?.user?._id ?? req?.user?.id,
|
||||
),
|
||||
tenantId: getCloudFrontScopeValue(
|
||||
options.tenantId ?? options.orgId,
|
||||
user?.tenantId ?? user?.orgId,
|
||||
req?.user?.tenantId ?? req?.user?.orgId,
|
||||
),
|
||||
...(storageRegion ? { storageRegion } : {}),
|
||||
};
|
||||
const skipReason = getCloudFrontAuthCookieSkipReason(scope);
|
||||
if (skipReason) {
|
||||
logger.debug('[setCloudFrontAuthCookies] CloudFront auth cookies skipped', {
|
||||
attempted: false,
|
||||
set: false,
|
||||
reason: skipReason,
|
||||
has_user_id: Boolean(scope.userId),
|
||||
has_tenant_scope: Boolean(scope.tenantId),
|
||||
has_storage_region: Boolean(scope.storageRegion),
|
||||
has_previous_scope: Boolean(getPreviousCloudFrontScope(req)?.userId),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
const previousScope = getPreviousCloudFrontScope(req);
|
||||
const cookiesSet = setCloudFrontCookies(res, scope, previousScope);
|
||||
logger.debug('[setCloudFrontAuthCookies] CloudFront auth cookies refreshed', {
|
||||
attempted: true,
|
||||
set: cookiesSet,
|
||||
reason: cookiesSet ? undefined : 'set_failed',
|
||||
has_user_id: true,
|
||||
has_tenant_scope: Boolean(scope.tenantId),
|
||||
has_storage_region: Boolean(scope.storageRegion),
|
||||
has_previous_scope: Boolean(previousScope?.userId),
|
||||
});
|
||||
return cookiesSet;
|
||||
};
|
||||
|
||||
/**
|
||||
* Set Auth Tokens
|
||||
* @param {String | ObjectId} userId
|
||||
|
|
@ -453,14 +533,7 @@ const setAuthTokens = async (userId, res, _session = null, req = null) => {
|
|||
sameSite: 'strict',
|
||||
});
|
||||
|
||||
setCloudFrontCookies(
|
||||
res,
|
||||
{
|
||||
userId: user?._id?.toString?.() ?? userId,
|
||||
tenantId: user?.tenantId?.toString?.(),
|
||||
},
|
||||
getPreviousCloudFrontScope(req),
|
||||
);
|
||||
setCloudFrontAuthCookies(req, res, user, { userId });
|
||||
|
||||
return token;
|
||||
} catch (error) {
|
||||
|
|
@ -609,14 +682,7 @@ const setOpenIDAuthTokens = (
|
|||
});
|
||||
}
|
||||
|
||||
setCloudFrontCookies(
|
||||
res,
|
||||
{
|
||||
userId,
|
||||
tenantId: tenantId ?? req.user?.tenantId,
|
||||
},
|
||||
getPreviousCloudFrontScope(req),
|
||||
);
|
||||
setCloudFrontAuthCookies(req, res, req.user, { userId, tenantId });
|
||||
|
||||
return appAuthToken;
|
||||
} catch (error) {
|
||||
|
|
@ -691,6 +757,7 @@ module.exports = {
|
|||
setAuthTokens,
|
||||
resetPassword,
|
||||
setOpenIDAuthTokens,
|
||||
setCloudFrontAuthCookies,
|
||||
requestPasswordReset,
|
||||
resendVerificationEmail,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -16,6 +16,13 @@ jest.mock('@librechat/api', () => ({
|
|||
shouldUseSecureCookie: jest.fn(() => false),
|
||||
resolveAppConfigForUser: jest.fn(async (_getAppConfig, _user) => ({})),
|
||||
setCloudFrontCookies: jest.fn(() => true),
|
||||
getCloudFrontConfig: jest.fn(() => ({
|
||||
domain: 'https://cdn.example.com',
|
||||
imageSigning: 'cookies',
|
||||
cookieDomain: '.example.com',
|
||||
privateKey: 'test-private-key',
|
||||
keyPairId: 'K123ABC',
|
||||
})),
|
||||
parseCloudFrontCookieScope: jest.fn(() => null),
|
||||
CLOUDFRONT_SCOPE_COOKIE: 'LibreChat-CloudFront-Scope',
|
||||
}));
|
||||
|
|
@ -44,8 +51,10 @@ const {
|
|||
isEmailDomainAllowed,
|
||||
resolveAppConfigForUser,
|
||||
setCloudFrontCookies,
|
||||
getCloudFrontConfig,
|
||||
parseCloudFrontCookieScope,
|
||||
} = require('@librechat/api');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const {
|
||||
findUser,
|
||||
getUserById,
|
||||
|
|
@ -54,7 +63,12 @@ const {
|
|||
createSession,
|
||||
} = require('~/models');
|
||||
const { getAppConfig } = require('~/server/services/Config');
|
||||
const { setOpenIDAuthTokens, requestPasswordReset, setAuthTokens } = require('./AuthService');
|
||||
const {
|
||||
setOpenIDAuthTokens,
|
||||
requestPasswordReset,
|
||||
setAuthTokens,
|
||||
setCloudFrontAuthCookies,
|
||||
} = require('./AuthService');
|
||||
|
||||
/** Helper to build a mock Express response */
|
||||
function mockResponse() {
|
||||
|
|
@ -376,8 +390,195 @@ describe('requestPasswordReset', () => {
|
|||
});
|
||||
|
||||
describe('CloudFront cookie integration', () => {
|
||||
const cloudFrontCookieConfig = {
|
||||
domain: 'https://cdn.example.com',
|
||||
imageSigning: 'cookies',
|
||||
cookieDomain: '.example.com',
|
||||
privateKey: 'test-private-key',
|
||||
keyPairId: 'K123ABC',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
getCloudFrontConfig.mockReturnValue(cloudFrontCookieConfig);
|
||||
setCloudFrontCookies.mockReturnValue(true);
|
||||
parseCloudFrontCookieScope.mockReturnValue(null);
|
||||
});
|
||||
|
||||
describe('setCloudFrontAuthCookies', () => {
|
||||
it('passes user id and tenant scope from the user', () => {
|
||||
const req = mockRequest();
|
||||
const res = mockResponse();
|
||||
const user = {
|
||||
_id: { toString: () => 'user-123' },
|
||||
tenantId: { toString: () => 'tenantA' },
|
||||
};
|
||||
|
||||
const result = setCloudFrontAuthCookies(req, res, user);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(setCloudFrontCookies).toHaveBeenCalledWith(
|
||||
res,
|
||||
{
|
||||
userId: 'user-123',
|
||||
tenantId: 'tenantA',
|
||||
},
|
||||
null,
|
||||
);
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
'[setCloudFrontAuthCookies] CloudFront auth cookies refreshed',
|
||||
expect.objectContaining({
|
||||
attempted: true,
|
||||
set: true,
|
||||
has_user_id: true,
|
||||
has_tenant_scope: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('lets explicit scope options override user and request scope', () => {
|
||||
const req = mockRequest();
|
||||
req.user = { _id: 'request-user', tenantId: 'request-tenant' };
|
||||
const res = mockResponse();
|
||||
const user = { _id: 'user-123', tenantId: 'tenantA' };
|
||||
|
||||
setCloudFrontAuthCookies(req, res, user, {
|
||||
userId: 'option-user',
|
||||
tenantId: 'option-tenant',
|
||||
storageRegion: 'us-east-2',
|
||||
});
|
||||
|
||||
expect(setCloudFrontCookies).toHaveBeenCalledWith(
|
||||
res,
|
||||
{
|
||||
userId: 'option-user',
|
||||
tenantId: 'option-tenant',
|
||||
storageRegion: 'us-east-2',
|
||||
},
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to request tenant scope when the user has none', () => {
|
||||
const req = mockRequest();
|
||||
req.user = { tenantId: 'request-tenant' };
|
||||
const res = mockResponse();
|
||||
|
||||
setCloudFrontAuthCookies(req, res, { _id: 'user-123' });
|
||||
|
||||
expect(setCloudFrontCookies).toHaveBeenCalledWith(
|
||||
res,
|
||||
{
|
||||
userId: 'user-123',
|
||||
tenantId: 'request-tenant',
|
||||
},
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it('uses org scope as tenant scope when tenantId is unavailable', () => {
|
||||
const req = mockRequest();
|
||||
const res = mockResponse();
|
||||
|
||||
setCloudFrontAuthCookies(req, res, { _id: 'user-123', orgId: 'orgA' });
|
||||
|
||||
expect(setCloudFrontCookies).toHaveBeenCalledWith(
|
||||
res,
|
||||
{
|
||||
userId: 'user-123',
|
||||
tenantId: 'orgA',
|
||||
},
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it('uses previous CloudFront scope for stale cookie cleanup', () => {
|
||||
parseCloudFrontCookieScope.mockReturnValue({ userId: 'old-user', tenantId: 'old-tenant' });
|
||||
const req = mockRequest({}, { 'LibreChat-CloudFront-Scope': 'encoded-scope' });
|
||||
const res = mockResponse();
|
||||
|
||||
setCloudFrontAuthCookies(req, res, { _id: 'user-123', tenantId: 'tenantA' });
|
||||
|
||||
expect(parseCloudFrontCookieScope).toHaveBeenCalledWith('encoded-scope');
|
||||
expect(setCloudFrontCookies).toHaveBeenCalledWith(
|
||||
res,
|
||||
{
|
||||
userId: 'user-123',
|
||||
tenantId: 'tenantA',
|
||||
},
|
||||
{ userId: 'old-user', tenantId: 'old-tenant' },
|
||||
);
|
||||
});
|
||||
|
||||
it('no-ops when CloudFront cookie signing is disabled', () => {
|
||||
getCloudFrontConfig.mockReturnValue({ ...cloudFrontCookieConfig, imageSigning: 'none' });
|
||||
const req = mockRequest();
|
||||
const res = mockResponse();
|
||||
|
||||
const result = setCloudFrontAuthCookies(req, res, { _id: 'user-123' });
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(setCloudFrontCookies).not.toHaveBeenCalled();
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
'[setCloudFrontAuthCookies] CloudFront auth cookies skipped',
|
||||
expect.objectContaining({
|
||||
attempted: false,
|
||||
set: false,
|
||||
reason: 'cloudfront_disabled',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('fails closed when user id is missing', () => {
|
||||
const req = mockRequest();
|
||||
const res = mockResponse();
|
||||
|
||||
const result = setCloudFrontAuthCookies(req, res, { tenantId: 'tenantA' });
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(setCloudFrontCookies).not.toHaveBeenCalled();
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
'[setCloudFrontAuthCookies] CloudFront auth cookies skipped',
|
||||
expect.objectContaining({
|
||||
attempted: false,
|
||||
set: false,
|
||||
reason: 'missing_user_id',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('skips when CloudFront cookie domain is missing', () => {
|
||||
getCloudFrontConfig.mockReturnValue({ ...cloudFrontCookieConfig, cookieDomain: null });
|
||||
const req = mockRequest();
|
||||
const res = mockResponse();
|
||||
|
||||
const result = setCloudFrontAuthCookies(req, res, { _id: 'user-123' });
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(setCloudFrontCookies).not.toHaveBeenCalled();
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
'[setCloudFrontAuthCookies] CloudFront auth cookies skipped',
|
||||
expect.objectContaining({
|
||||
attempted: false,
|
||||
set: false,
|
||||
reason: 'missing_cookie_domain',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not log cookie secrets or signed-cookie values', () => {
|
||||
const req = mockRequest();
|
||||
const res = mockResponse();
|
||||
|
||||
setCloudFrontAuthCookies(req, res, { _id: 'user-123' });
|
||||
|
||||
const debugOutput = JSON.stringify(logger.debug.mock.calls);
|
||||
expect(debugOutput).not.toContain('test-private-key');
|
||||
expect(debugOutput).not.toContain('K123ABC');
|
||||
expect(debugOutput).not.toContain('CloudFront-Policy');
|
||||
expect(debugOutput).not.toContain('CloudFront-Signature');
|
||||
expect(debugOutput).not.toContain('CloudFront-Key-Pair-Id');
|
||||
});
|
||||
});
|
||||
|
||||
describe('setOpenIDAuthTokens', () => {
|
||||
|
|
@ -429,13 +630,14 @@ describe('CloudFront cookie integration', () => {
|
|||
const result = setOpenIDAuthTokens(validTokenset, req, res, null);
|
||||
|
||||
expect(result).toBe('the-id-token');
|
||||
expect(setCloudFrontCookies).toHaveBeenCalledWith(
|
||||
res,
|
||||
{
|
||||
userId: null,
|
||||
tenantId: undefined,
|
||||
},
|
||||
null,
|
||||
expect(setCloudFrontCookies).not.toHaveBeenCalled();
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
'[setCloudFrontAuthCookies] CloudFront auth cookies skipped',
|
||||
expect.objectContaining({
|
||||
attempted: false,
|
||||
set: false,
|
||||
reason: 'missing_user_id',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -446,13 +648,14 @@ describe('CloudFront cookie integration', () => {
|
|||
const result = setOpenIDAuthTokens(validTokenset, req, res);
|
||||
|
||||
expect(result).toBe('the-id-token');
|
||||
expect(setCloudFrontCookies).toHaveBeenCalledWith(
|
||||
res,
|
||||
{
|
||||
userId: null,
|
||||
tenantId: undefined,
|
||||
},
|
||||
null,
|
||||
expect(setCloudFrontCookies).not.toHaveBeenCalled();
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
'[setCloudFrontAuthCookies] CloudFront auth cookies skipped',
|
||||
expect.objectContaining({
|
||||
attempted: false,
|
||||
set: false,
|
||||
reason: 'missing_user_id',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -463,13 +666,14 @@ describe('CloudFront cookie integration', () => {
|
|||
const result = setOpenIDAuthTokens(validTokenset, req, res, {});
|
||||
|
||||
expect(result).toBe('the-id-token');
|
||||
expect(setCloudFrontCookies).toHaveBeenCalledWith(
|
||||
res,
|
||||
{
|
||||
userId: undefined,
|
||||
tenantId: undefined,
|
||||
},
|
||||
null,
|
||||
expect(setCloudFrontCookies).not.toHaveBeenCalled();
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
'[setCloudFrontAuthCookies] CloudFront auth cookies skipped',
|
||||
expect.objectContaining({
|
||||
attempted: false,
|
||||
set: false,
|
||||
reason: 'missing_user_id',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue