diff --git a/api/server/services/AuthService.js b/api/server/services/AuthService.js index c3aca089d4..0ffa8c6aee 100644 --- a/api/server/services/AuthService.js +++ b/api/server/services/AuthService.js @@ -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, diff --git a/api/server/services/AuthService.spec.js b/api/server/services/AuthService.spec.js index 3fce12cc20..84f758078a 100644 --- a/api/server/services/AuthService.spec.js +++ b/api/server/services/AuthService.spec.js @@ -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', () => { diff --git a/packages/data-schemas/src/methods/token.spec.ts b/packages/data-schemas/src/methods/token.spec.ts index 87c3916bf8..9508784fea 100644 --- a/packages/data-schemas/src/methods/token.spec.ts +++ b/packages/data-schemas/src/methods/token.spec.ts @@ -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', diff --git a/packages/data-schemas/src/methods/token.ts b/packages/data-schemas/src/methods/token.ts index a5de3d2a5d..76fcd292dd 100644 --- a/packages/data-schemas/src/methods/token.ts +++ b/packages/data-schemas/src/methods/token.ts @@ -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 }); } diff --git a/packages/data-schemas/src/types/token.ts b/packages/data-schemas/src/types/token.ts index fd5bfa3a8a..bc61f9f136 100644 --- a/packages/data-schemas/src/types/token.ts +++ b/packages/data-schemas/src/types/token.ts @@ -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 {