mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +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
|
|
@ -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', () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue