🏷️ fix: Categorize Auth Tokens by Flow Type (#13556)

* fix: Scope auth token lifecycle

* fix: Preserve legacy auth token lookup

* fix: Scope verification token cleanup
This commit is contained in:
Danny Avila 2026-06-06 14:22:06 -04:00 committed by GitHub
parent 5011be4d38
commit 3571dfcf22
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 545 additions and 49 deletions

View file

@ -1,32 +1,44 @@
jest.mock('@librechat/data-schemas', () => ({
logger: { info: jest.fn(), warn: jest.fn(), debug: jest.fn(), error: jest.fn() },
getTenantId: jest.fn(() => undefined),
DEFAULT_SESSION_EXPIRY: 900000,
DEFAULT_REFRESH_TOKEN_EXPIRY: 604800000,
}));
jest.mock('librechat-data-provider', () => ({
ErrorTypes: {},
SystemRoles: { USER: 'USER', ADMIN: 'ADMIN' },
errorsToString: jest.fn(),
}));
jest.mock('@librechat/api', () => ({
isEnabled: jest.fn((val) => val === 'true' || val === true),
checkEmailConfig: jest.fn(),
isEmailDomainAllowed: jest.fn(),
math: jest.fn((val, fallback) => (val ? Number(val) : fallback)),
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',
}));
jest.mock(
'@librechat/data-schemas',
() => ({
logger: { info: jest.fn(), warn: jest.fn(), debug: jest.fn(), error: jest.fn() },
getTenantId: jest.fn(() => undefined),
DEFAULT_SESSION_EXPIRY: 900000,
DEFAULT_REFRESH_TOKEN_EXPIRY: 604800000,
}),
{ virtual: true },
);
jest.mock(
'librechat-data-provider',
() => ({
ErrorTypes: {},
SystemRoles: { USER: 'USER', ADMIN: 'ADMIN' },
errorsToString: jest.fn(),
}),
{ virtual: true },
);
jest.mock(
'@librechat/api',
() => ({
isEnabled: jest.fn((val) => val === 'true' || val === true),
checkEmailConfig: jest.fn(),
isEmailDomainAllowed: jest.fn(),
math: jest.fn((val, fallback) => (val ? Number(val) : fallback)),
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',
}),
{ virtual: true },
);
jest.mock('~/models', () => ({
findUser: jest.fn(),
findToken: jest.fn(),
@ -73,6 +85,7 @@ const jwt = require('jsonwebtoken');
const { logger, getTenantId } = require('@librechat/data-schemas');
const {
findUser,
findToken,
createUser,
updateUser,
countUsers,
@ -80,14 +93,21 @@ const {
generateToken,
generateRefreshToken,
createSession,
createToken,
deleteTokens,
} = require('~/models');
const { getAppConfig } = require('~/server/services/Config');
const { sendEmail } = require('~/server/utils');
const bcrypt = require('bcryptjs');
const {
setOpenIDAuthTokens,
requestPasswordReset,
registerUser,
resetPassword,
resendVerificationEmail,
setAuthTokens,
setCloudFrontAuthCookies,
verifyEmail,
} = require('./AuthService');
/** Helper to build a mock Express response */
@ -516,6 +536,305 @@ describe('requestPasswordReset', () => {
expect(result).not.toBeInstanceOf(Error);
expect(result.message).toContain('If an account with that email exists');
});
it('should only delete existing password reset tokens when issuing a new reset link', async () => {
const user = { _id: 'user-reset', email: 'user@example.com' };
findUser.mockResolvedValue(user);
const req = { body: { email: 'user@example.com' }, ip: '127.0.0.1' };
await requestPasswordReset(req);
expect(deleteTokens).toHaveBeenCalledWith({
userId: user._id,
type: 'password_reset',
});
expect(deleteTokens).toHaveBeenCalledWith({
userId: user._id,
email: null,
identifier: null,
type: null,
});
expect(createToken).toHaveBeenCalledWith(
expect.objectContaining({
userId: user._id,
type: 'password_reset',
}),
);
});
});
describe('resetPassword', () => {
beforeEach(() => {
jest.clearAllMocks();
checkEmailConfig.mockReturnValue(false);
});
it('should only accept password reset tokens for password reset', async () => {
const verificationHash = bcrypt.hashSync('verification-token', 10);
findToken.mockImplementation(async (query) => {
if (query.type === 'password_reset') {
return null;
}
if (query.type === null && query.email === null && query.identifier === null) {
return null;
}
return { token: verificationHash, userId: 'user-reset', email: 'user@example.com' };
});
updateUser.mockResolvedValue({ email: 'user@example.com' });
const result = await resetPassword('user-reset', 'verification-token', 'new-password');
expect(result).toBeInstanceOf(Error);
expect(findToken).toHaveBeenCalledWith(
{
userId: 'user-reset',
type: 'password_reset',
},
{ sort: { createdAt: -1 } },
);
expect(findToken).toHaveBeenCalledWith(
{
userId: 'user-reset',
email: null,
identifier: null,
type: null,
},
{ sort: { createdAt: -1 } },
);
expect(updateUser).not.toHaveBeenCalled();
expect(deleteTokens).not.toHaveBeenCalled();
});
it('should delete only the used password reset token after a successful reset', async () => {
const resetHash = bcrypt.hashSync('reset-token', 10);
findToken.mockResolvedValue({
token: resetHash,
userId: 'user-reset',
type: 'password_reset',
});
updateUser.mockResolvedValue({ email: 'user@example.com' });
const result = await resetPassword('user-reset', 'reset-token', 'new-password');
expect(result).toEqual({ message: 'Password reset was successful' });
expect(findToken).toHaveBeenCalledWith(
{
userId: 'user-reset',
type: 'password_reset',
},
{ sort: { createdAt: -1 } },
);
expect(deleteTokens).toHaveBeenCalledWith({
token: resetHash,
type: 'password_reset',
});
});
it('should accept legacy reset tokens without affecting verification-shaped tokens', async () => {
const legacyResetHash = bcrypt.hashSync('legacy-reset-token', 10);
findToken.mockImplementation(async (query) => {
if (query.type === 'password_reset') {
return null;
}
if (query.type === null && query.email === null && query.identifier === null) {
return {
token: legacyResetHash,
userId: 'user-reset',
};
}
return null;
});
updateUser.mockResolvedValue({ email: 'user@example.com' });
const result = await resetPassword('user-reset', 'legacy-reset-token', 'new-password');
expect(result).toEqual({ message: 'Password reset was successful' });
expect(findToken).toHaveBeenCalledWith(
{
userId: 'user-reset',
type: 'password_reset',
},
{ sort: { createdAt: -1 } },
);
expect(findToken).toHaveBeenCalledWith(
{
userId: 'user-reset',
email: null,
identifier: null,
type: null,
},
{ sort: { createdAt: -1 } },
);
expect(deleteTokens).toHaveBeenCalledWith({
token: legacyResetHash,
email: null,
identifier: null,
type: null,
});
});
});
describe('verifyEmail', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should scope verification token lookup to the user and token category', async () => {
const verificationHash = bcrypt.hashSync('verification-token', 10);
const user = {
_id: 'user-verify',
email: 'user@example.com',
emailVerified: false,
};
findUser.mockResolvedValue(user);
findToken.mockImplementation(async (query) => {
if (query.type === 'email_verification') {
return {
userId: user._id,
email: user.email,
token: verificationHash,
type: 'email_verification',
};
}
return null;
});
updateUser.mockResolvedValue({ ...user, emailVerified: true });
const result = await verifyEmail({
body: {
email: encodeURIComponent(user.email),
token: 'verification-token',
},
});
expect(result).toEqual({
message: 'Email verification was successful',
status: 'success',
});
expect(findToken).toHaveBeenCalledWith(
{
userId: user._id,
email: user.email,
type: 'email_verification',
},
{ sort: { createdAt: -1 } },
);
expect(deleteTokens).toHaveBeenCalledWith({
token: verificationHash,
type: 'email_verification',
});
});
it('should fall back only to legacy verification tokens for the same user', async () => {
const verificationHash = bcrypt.hashSync('legacy-verification-token', 10);
const user = {
_id: 'user-verify',
email: 'user@example.com',
emailVerified: false,
};
findUser.mockResolvedValue(user);
findToken.mockImplementation(async (query) => {
if (query.type === 'email_verification') {
return null;
}
if (query.type === null && query.identifier === null && query.userId === user._id) {
return {
userId: user._id,
email: user.email,
token: verificationHash,
};
}
return null;
});
updateUser.mockResolvedValue({ ...user, emailVerified: true });
const result = await verifyEmail({
body: {
email: encodeURIComponent(user.email),
token: 'legacy-verification-token',
},
});
expect(result).toEqual({
message: 'Email verification was successful',
status: 'success',
});
expect(findToken).toHaveBeenCalledWith(
{
userId: user._id,
email: user.email,
identifier: null,
type: null,
},
{ sort: { createdAt: -1 } },
);
expect(deleteTokens).toHaveBeenCalledWith({
token: verificationHash,
userId: user._id,
email: user.email,
identifier: null,
type: null,
});
});
});
describe('resendVerificationEmail', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should not delete tokens when no user exists for the email', async () => {
findUser.mockResolvedValue(null);
const result = await resendVerificationEmail({
body: { email: 'missing@example.com' },
});
expect(result).toEqual({
status: 200,
message: 'Please check your email to verify your email address.',
});
expect(deleteTokens).not.toHaveBeenCalled();
expect(sendEmail).not.toHaveBeenCalled();
expect(createToken).not.toHaveBeenCalled();
});
it('should delete only verification tokens scoped to the resolved user', async () => {
const user = {
_id: 'user-verify',
email: 'user@example.com',
name: 'User Verify',
};
findUser.mockResolvedValue(user);
const result = await resendVerificationEmail({
body: { email: user.email },
});
expect(result).toEqual({
status: 200,
message: 'Please check your email to verify your email address.',
});
expect(deleteTokens).toHaveBeenCalledWith({
userId: user._id,
email: user.email,
type: 'email_verification',
});
expect(deleteTokens).toHaveBeenCalledWith({
userId: user._id,
email: user.email,
identifier: null,
type: null,
});
expect(deleteTokens).not.toHaveBeenCalledWith({ email: user.email });
expect(createToken).toHaveBeenCalledWith(
expect.objectContaining({
userId: user._id,
email: user.email,
type: 'email_verification',
}),
);
});
});
describe('CloudFront cookie integration', () => {