mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-06-09 17:31:19 +00:00
🏷️ 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:
parent
5011be4d38
commit
3571dfcf22
5 changed files with 545 additions and 49 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue