🍪 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:
Danny Avila 2026-05-11 22:33:27 -04:00 committed by GitHub
parent 929082387f
commit 17a08224e1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 465 additions and 43 deletions

View file

@ -11,6 +11,7 @@ const {
const {
requestPasswordReset,
setOpenIDAuthTokens,
setCloudFrontAuthCookies,
resetPassword,
setAuthTokens,
registerUser,
@ -25,6 +26,8 @@ const {
const { getGraphApiToken } = require('~/server/services/GraphTokenService');
const { getOpenIdConfig, getOpenIdEmail } = require('~/strategies');
const AUTH_REFRESH_USER_PROJECTION = '-password -__v -totpSecret -backupCodes -federatedTokens';
const registrationController = async (req, res) => {
try {
const response = await registerUser(req.body);
@ -36,6 +39,55 @@ const registrationController = async (req, res) => {
}
};
const sanitizeUserForAuthResponse = (user) => {
const source = (typeof user?.toObject === 'function' ? user.toObject() : user) || {};
const {
password: _pw,
__v: _v,
totpSecret: _ts,
backupCodes: _bc,
federatedTokens: _ft,
...safeUser
} = source;
return safeUser;
};
const getValidOpenIDReuseUserId = (parsedCookies) => {
const openidUserId = parsedCookies.openid_user_id;
if (!openidUserId || !process.env.JWT_REFRESH_SECRET) {
return null;
}
try {
const payload = jwt.verify(openidUserId, process.env.JWT_REFRESH_SECRET);
return typeof payload === 'object' && payload != null && typeof payload.id === 'string'
? payload.id
: null;
} catch {
return null;
}
};
const getReusableOpenIDSessionToken = (openidTokens) => {
const candidates = [
{ token: openidTokens?.idToken, type: 'id_token' },
{ token: openidTokens?.accessToken, type: 'access_token' },
];
const now = Math.floor(Date.now() / 1000);
for (const candidate of candidates) {
if (!candidate.token) {
continue;
}
const decoded = jwt.decode(candidate.token);
if (decoded && typeof decoded === 'object' && decoded.exp > now) {
return candidate;
}
}
return null;
};
const resetPasswordRequestController = async (req, res) => {
try {
const resetService = await requestPasswordReset(req);
@ -82,6 +134,26 @@ const refreshController = async (req, res) => {
}
try {
const reusableSessionToken = getReusableOpenIDSessionToken(req.session?.openidTokens);
const reuseUserId = reusableSessionToken ? getValidOpenIDReuseUserId(parsedCookies) : null;
if (reuseUserId) {
const user = await getUserById(reuseUserId, AUTH_REFRESH_USER_PROJECTION);
if (user) {
const cloudFrontCookiesSet = setCloudFrontAuthCookies(req, res, user);
logger.debug('[refreshController] OpenID session token reused', {
token_type: reusableSessionToken.type,
has_id_token: Boolean(req.session?.openidTokens?.idToken),
has_access_token: Boolean(req.session?.openidTokens?.accessToken),
cloudfront_cookies_attempted: true,
cloudfront_cookies_set: cloudFrontCookiesSet,
});
return res.status(200).send({
token: reusableSessionToken.token,
user: sanitizeUserForAuthResponse(user),
});
}
}
const openIdConfig = getOpenIdConfig();
const refreshParams = buildOpenIDRefreshParams();
logger.debug('[refreshController] OpenID refresh params', {
@ -141,8 +213,7 @@ const refreshController = async (req, res) => {
tenantId: user.tenantId,
});
const { password: _pw, __v: _v, totpSecret: _ts, backupCodes: _bc, ...safeUser } = user;
return res.status(200).send({ token, user: safeUser });
return res.status(200).send({ token, user: sanitizeUserForAuthResponse(user) });
} catch (error) {
logger.error('[refreshController] OpenID token refresh error', error);
return res.status(403).send('Invalid OpenID refresh token');
@ -157,7 +228,7 @@ const refreshController = async (req, res) => {
try {
const payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
const user = await getUserById(payload.id, '-password -__v -totpSecret -backupCodes');
const user = await getUserById(payload.id, AUTH_REFRESH_USER_PROJECTION);
if (!user) {
return res.status(401).redirect('/login');
}

View file

@ -7,6 +7,7 @@ jest.mock('~/server/services/GraphTokenService', () => ({
jest.mock('~/server/services/AuthService', () => ({
requestPasswordReset: jest.fn(),
setOpenIDAuthTokens: jest.fn(),
setCloudFrontAuthCookies: jest.fn(),
resetPassword: jest.fn(),
setAuthTokens: jest.fn(),
registerUser: jest.fn(),
@ -37,16 +38,18 @@ jest.mock('@librechat/api', () => ({
}));
const openIdClient = require('openid-client');
const jwt = require('jsonwebtoken');
const { logger } = require('@librechat/data-schemas');
const { isEnabled, findOpenIDUser, buildOpenIDRefreshParams } = require('@librechat/api');
const { graphTokenController, refreshController } = require('./AuthController');
const { getGraphApiToken } = require('~/server/services/GraphTokenService');
const { setOpenIDAuthTokens } = require('~/server/services/AuthService');
const { setOpenIDAuthTokens, setCloudFrontAuthCookies } = require('~/server/services/AuthService');
const { getOpenIdConfig, getOpenIdEmail } = require('~/strategies');
const { updateUser } = require('~/models');
const { getUserById, updateUser } = require('~/models');
const ORIGINAL_OPENID_SCOPE = process.env.OPENID_SCOPE;
const ORIGINAL_OPENID_REFRESH_AUDIENCE = process.env.OPENID_REFRESH_AUDIENCE;
const ORIGINAL_JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET;
describe('graphTokenController', () => {
let req, res;
@ -196,6 +199,7 @@ describe('refreshController OpenID path', () => {
jest.clearAllMocks();
delete process.env.OPENID_SCOPE;
delete process.env.OPENID_REFRESH_AUDIENCE;
process.env.JWT_REFRESH_SECRET = 'test-refresh-secret';
isEnabled.mockReturnValue(true);
getOpenIdConfig.mockReturnValue({ some: 'config' });
@ -203,7 +207,13 @@ describe('refreshController OpenID path', () => {
mockTokenset.claims.mockReturnValue(baseClaims);
getOpenIdEmail.mockReturnValue(baseClaims.email);
setOpenIDAuthTokens.mockReturnValue('new-app-token');
setCloudFrontAuthCookies.mockReturnValue(true);
findOpenIDUser.mockResolvedValue({ user: { ...defaultUser }, error: null, migration: false });
getUserById.mockResolvedValue({
_id: 'user-db-id',
email: baseClaims.email,
openidId: baseClaims.sub,
});
updateUser.mockResolvedValue({});
req = {
@ -230,6 +240,12 @@ describe('refreshController OpenID path', () => {
} else {
process.env.OPENID_REFRESH_AUDIENCE = ORIGINAL_OPENID_REFRESH_AUDIENCE;
}
if (ORIGINAL_JWT_REFRESH_SECRET === undefined) {
delete process.env.JWT_REFRESH_SECRET;
} else {
process.env.JWT_REFRESH_SECRET = ORIGINAL_JWT_REFRESH_SECRET;
}
});
it('should call getOpenIdEmail with token claims and use result for findOpenIDUser', async () => {
@ -246,6 +262,70 @@ describe('refreshController OpenID path', () => {
expect(res.status).toHaveBeenCalledWith(200);
});
it('reuses valid OpenID session tokens and refreshes CloudFront cookies', async () => {
const reusableIdToken = jwt.sign(
{ sub: baseClaims.sub, exp: Math.floor(Date.now() / 1000) + 3600 },
'idp-signing-secret',
);
const signedUserId = jwt.sign({ id: 'user-db-id' }, process.env.JWT_REFRESH_SECRET, {
expiresIn: '1h',
});
req.headers.cookie = [
'token_provider=openid',
'refreshToken=stored-refresh',
`openid_user_id=${signedUserId}`,
].join('; ');
req.session = {
openidTokens: {
accessToken: 'session-access-token',
idToken: reusableIdToken,
refreshToken: 'stored-refresh',
},
};
const user = {
...defaultUser,
federatedTokens: { access_token: 'do-not-return' },
};
getUserById.mockResolvedValue(user);
await refreshController(req, res);
expect(openIdClient.refreshTokenGrant).not.toHaveBeenCalled();
expect(setOpenIDAuthTokens).not.toHaveBeenCalled();
expect(getUserById).toHaveBeenCalledWith(
'user-db-id',
'-password -__v -totpSecret -backupCodes -federatedTokens',
);
expect(setCloudFrontAuthCookies).toHaveBeenCalledWith(req, res, user);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.send).toHaveBeenCalledWith({
token: reusableIdToken,
user: expect.objectContaining({
_id: 'user-db-id',
email: baseClaims.email,
openidId: baseClaims.sub,
}),
});
const sentPayload = res.send.mock.calls[0][0];
expect(sentPayload.user).not.toHaveProperty('password');
expect(sentPayload.user).not.toHaveProperty('totpSecret');
expect(sentPayload.user).not.toHaveProperty('backupCodes');
expect(sentPayload.user).not.toHaveProperty('federatedTokens');
expect(logger.debug).toHaveBeenCalledWith(
'[refreshController] OpenID session token reused',
expect.objectContaining({
token_type: 'id_token',
cloudfront_cookies_attempted: true,
cloudfront_cookies_set: true,
}),
);
const debugOutput = JSON.stringify(logger.debug.mock.calls);
expect(debugOutput).not.toContain(reusableIdToken);
expect(debugOutput).not.toContain(signedUserId);
expect(debugOutput).not.toContain('session-access-token');
});
it('should pass scope-only OpenID refresh params when OPENID_SCOPE is set', async () => {
process.env.OPENID_SCOPE = 'openid profile email';

View file

@ -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,
};

View file

@ -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',
}),
);
});