🏷️ 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

@ -45,9 +45,112 @@ const domains = {
server: process.env.DOMAIN_SERVER,
};
const AuthTokenTypes = Object.freeze({
EMAIL_VERIFICATION: 'email_verification',
PASSWORD_RESET: 'password_reset',
});
const latestAuthTokenOptions = Object.freeze({ sort: { createdAt: -1 } });
const genericVerificationMessage = 'Please check your email to verify your email address.';
const OPENID_SESSION_ID_TOKEN_EXPIRY_BUFFER_SECONDS = 30;
const findPasswordResetToken = async (userId) => {
const typedToken = await findToken(
{
userId,
type: AuthTokenTypes.PASSWORD_RESET,
},
latestAuthTokenOptions,
);
if (typedToken) {
return typedToken;
}
return await findToken(
{
userId,
email: null,
identifier: null,
type: null,
},
latestAuthTokenOptions,
);
};
const findEmailVerificationToken = async (user) => {
const typedToken = await findToken(
{
userId: user._id,
email: user.email,
type: AuthTokenTypes.EMAIL_VERIFICATION,
},
latestAuthTokenOptions,
);
if (typedToken) {
return typedToken;
}
return await findToken(
{
userId: user._id,
email: user.email,
identifier: null,
type: null,
},
latestAuthTokenOptions,
);
};
const deleteEmailVerificationTokens = (user) =>
Promise.all([
deleteTokens({
userId: user._id,
email: user.email,
type: AuthTokenTypes.EMAIL_VERIFICATION,
}),
deleteTokens({
userId: user._id,
email: user.email,
identifier: null,
type: null,
}),
]);
const getEmailVerificationTokenDeleteQuery = (emailVerificationToken) => {
if (!emailVerificationToken.identifier && !emailVerificationToken.type) {
return {
token: emailVerificationToken.token,
userId: emailVerificationToken.userId,
email: emailVerificationToken.email,
identifier: null,
type: null,
};
}
return {
token: emailVerificationToken.token,
type: AuthTokenTypes.EMAIL_VERIFICATION,
};
};
const getPasswordResetTokenDeleteQuery = (passwordResetToken) => {
if (!passwordResetToken.email && !passwordResetToken.type) {
return {
token: passwordResetToken.token,
email: null,
identifier: null,
type: null,
};
}
return {
token: passwordResetToken.token,
type: AuthTokenTypes.PASSWORD_RESET,
};
};
const getUnexpiredOpenIDSessionIdToken = (idToken) => {
if (!idToken) {
return;
@ -133,6 +236,7 @@ const sendVerificationEmail = async (user) => {
await createToken({
userId: user._id,
email: user.email,
type: AuthTokenTypes.EMAIL_VERIFICATION,
token: hash,
createdAt: Date.now(),
expiresIn: 900,
@ -161,11 +265,11 @@ const verifyEmail = async (req) => {
return { message: 'Email already verified', status: 'success' };
}
let emailVerificationData = await findToken({ email: decodedEmail }, { sort: { createdAt: -1 } });
const emailVerificationData = await findEmailVerificationToken(user);
if (!emailVerificationData) {
logger.warn(`[verifyEmail] [No email verification data found] [Email: ${decodedEmail}]`);
return new Error('Invalid or expired password reset token');
return new Error('Invalid or expired email verification token');
}
const isValid = bcrypt.compareSync(token, emailVerificationData.token);
@ -184,7 +288,7 @@ const verifyEmail = async (req) => {
return new Error('Failed to update user verification status');
}
await deleteTokens({ token: emailVerificationData.token });
await deleteTokens(getEmailVerificationTokenDeleteQuery(emailVerificationData));
logger.info(`[verifyEmail] Email verification successful [Email: ${decodedEmail}]`);
return { message: 'Email verification was successful', status: 'success' };
};
@ -337,12 +441,16 @@ const requestPasswordReset = async (req) => {
};
}
await deleteTokens({ userId: user._id });
await Promise.all([
deleteTokens({ userId: user._id, type: AuthTokenTypes.PASSWORD_RESET }),
deleteTokens({ userId: user._id, email: null, identifier: null, type: null }),
]);
const [resetToken, hash] = createTokenHash();
await createToken({
userId: user._id,
type: AuthTokenTypes.PASSWORD_RESET,
token: hash,
createdAt: Date.now(),
expiresIn: 900,
@ -386,12 +494,7 @@ const requestPasswordReset = async (req) => {
* @returns
*/
const resetPassword = async (userId, token, password) => {
let passwordResetToken = await findToken(
{
userId,
},
{ sort: { createdAt: -1 } },
);
const passwordResetToken = await findPasswordResetToken(userId);
if (!passwordResetToken) {
return new Error('Invalid or expired password reset token');
@ -419,7 +522,7 @@ const resetPassword = async (userId, token, password) => {
});
}
await deleteTokens({ token: passwordResetToken.token });
await deleteTokens(getPasswordResetTokenDeleteQuery(passwordResetToken));
logger.info(`[resetPassword] Password reset successful. [Email: ${user.email}]`);
return { message: 'Password reset was successful' };
};
@ -724,7 +827,6 @@ const setOpenIDAuthTokens = (
const resendVerificationEmail = async (req) => {
try {
const { email } = req.body;
await deleteTokens({ email });
const user = await findUser({ email }, 'email _id name');
if (!user) {
@ -732,6 +834,8 @@ const resendVerificationEmail = async (req) => {
return { status: 200, message: genericVerificationMessage };
}
await deleteEmailVerificationTokens(user);
const [verifyToken, hash] = createTokenHash();
const verificationLink = `${
@ -753,6 +857,7 @@ const resendVerificationEmail = async (req) => {
await createToken({
userId: user._id,
email: user.email,
type: AuthTokenTypes.EMAIL_VERIFICATION,
token: hash,
createdAt: Date.now(),
expiresIn: 900,

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', () => {

View file

@ -149,6 +149,35 @@ describe('Token Methods - Detailed Tests', () => {
expect(found?.userId.toString()).toBe(user2Id.toString());
});
test('should find tokens with explicitly absent optional fields', async () => {
const legacyUserId = new mongoose.Types.ObjectId();
await Token.create([
{
token: 'legacy-reset-token',
userId: legacyUserId,
createdAt: new Date(),
expiresAt: new Date(Date.now() + 3600000),
},
{
token: 'legacy-identified-token',
userId: legacyUserId,
identifier: 'oauth-legacy',
createdAt: new Date(),
expiresAt: new Date(Date.now() + 3600000),
},
]);
const found = await methods.findToken({
userId: legacyUserId.toString(),
email: null,
identifier: null,
type: null,
});
expect(found).toBeDefined();
expect(found?.token).toBe('legacy-reset-token');
});
test('should find token by identifier', async () => {
const found = await methods.findToken({ identifier: 'oauth-123' });
@ -566,6 +595,47 @@ describe('Token Methods - Detailed Tests', () => {
expect(remainingTokens).toHaveLength(3);
});
test('should delete only tokens with explicitly absent optional fields', async () => {
const legacyUserId = new mongoose.Types.ObjectId();
await Token.create([
{
token: 'legacy-reset-token',
userId: legacyUserId,
createdAt: new Date(),
expiresAt: new Date(Date.now() + 3600000),
},
{
token: 'legacy-identified-token',
userId: legacyUserId,
identifier: 'oauth-legacy',
createdAt: new Date(),
expiresAt: new Date(Date.now() + 3600000),
},
{
token: 'typed-reset-token',
userId: legacyUserId,
type: 'password_reset',
createdAt: new Date(),
expiresAt: new Date(Date.now() + 3600000),
},
]);
const result = await methods.deleteTokens({
userId: legacyUserId.toString(),
email: null,
identifier: null,
type: null,
});
expect(result.deletedCount).toBe(1);
const remainingTokens = await Token.find({ userId: legacyUserId });
expect(remainingTokens).toHaveLength(2);
expect(remainingTokens.find((t) => t.token === 'legacy-reset-token')).toBeUndefined();
expect(remainingTokens.find((t) => t.token === 'legacy-identified-token')).toBeDefined();
expect(remainingTokens.find((t) => t.token === 'typed-reset-token')).toBeDefined();
});
test('should only delete tokens matching ALL provided fields (AND semantics)', async () => {
await Token.create({
token: 'extra-user2-token',

View file

@ -61,7 +61,8 @@ export function createTokenMethods(mongoose: typeof import('mongoose')) {
conditions.push({ token: query.token });
}
if (query.email !== undefined) {
conditions.push({ email: query.email.trim().toLowerCase() });
const email = query.email === null ? null : query.email.trim().toLowerCase();
conditions.push({ email });
}
if (query.type !== undefined) {
conditions.push({ type: query.type });
@ -98,13 +99,14 @@ export function createTokenMethods(mongoose: typeof import('mongoose')) {
if (query.token) {
conditions.push({ token: query.token });
}
if (query.email) {
conditions.push({ email: query.email.trim().toLowerCase() });
if (query.email !== undefined) {
const email = query.email === null ? null : query.email.trim().toLowerCase();
conditions.push({ email });
}
if (query.type) {
if (query.type !== undefined) {
conditions.push({ type: query.type });
}
if (query.identifier) {
if (query.identifier !== undefined) {
conditions.push({ identifier: query.identifier });
}

View file

@ -25,9 +25,9 @@ export interface TokenCreateData {
export interface TokenQuery {
userId?: Types.ObjectId | string;
token?: string;
email?: string;
type?: string;
identifier?: string;
email?: string | null;
type?: string | null;
identifier?: string | null;
}
export interface TokenUpdateData {