mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-09-22 06:00:56 +02:00
🔒 fix: Provider Validation for Social, OpenID, SAML, and LDAP Logins (#8999)
* fix: social login provider crossover * feat: Enhance OpenID login handling and add tests for provider validation * refactor: authentication error handling to use ErrorTypes.AUTH_FAILED enum * refactor: update authentication error handling in LDAP and SAML strategies to use ErrorTypes.AUTH_FAILED enum * ci: Add validation for login with existing email and different provider in SAML strategy chore: Add logging for existing users with different providers in LDAP, SAML, and Social Login strategies
This commit is contained in:
parent
04d74a7e07
commit
1ccac58403
18 changed files with 314 additions and 125 deletions
|
@ -1,46 +0,0 @@
|
|||
const { logger } = require('~/config');
|
||||
|
||||
//handle duplicates
|
||||
const handleDuplicateKeyError = (err, res) => {
|
||||
logger.error('Duplicate key error:', err.keyValue);
|
||||
const field = `${JSON.stringify(Object.keys(err.keyValue))}`;
|
||||
const code = 409;
|
||||
res
|
||||
.status(code)
|
||||
.send({ messages: `An document with that ${field} already exists.`, fields: field });
|
||||
};
|
||||
|
||||
//handle validation errors
|
||||
const handleValidationError = (err, res) => {
|
||||
logger.error('Validation error:', err.errors);
|
||||
let errors = Object.values(err.errors).map((el) => el.message);
|
||||
let fields = `${JSON.stringify(Object.values(err.errors).map((el) => el.path))}`;
|
||||
let code = 400;
|
||||
if (errors.length > 1) {
|
||||
errors = errors.join(' ');
|
||||
res.status(code).send({ messages: `${JSON.stringify(errors)}`, fields: fields });
|
||||
} else {
|
||||
res.status(code).send({ messages: `${JSON.stringify(errors)}`, fields: fields });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = (err, _req, res, _next) => {
|
||||
try {
|
||||
if (err.name === 'ValidationError') {
|
||||
return handleValidationError(err, res);
|
||||
}
|
||||
if (err.code && err.code == 11000) {
|
||||
return handleDuplicateKeyError(err, res);
|
||||
}
|
||||
// Special handling for errors like SyntaxError
|
||||
if (err.statusCode && err.body) {
|
||||
return res.status(err.statusCode).send(err.body);
|
||||
}
|
||||
|
||||
logger.error('ErrorController => error', err);
|
||||
return res.status(500).send('An unknown error occurred.');
|
||||
} catch (err) {
|
||||
logger.error('ErrorController => processing error', err);
|
||||
return res.status(500).send('Processing error in ErrorController.');
|
||||
}
|
||||
};
|
|
@ -8,14 +8,12 @@ const express = require('express');
|
|||
const passport = require('passport');
|
||||
const compression = require('compression');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const { isEnabled } = require('@librechat/api');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const mongoSanitize = require('express-mongo-sanitize');
|
||||
const { isEnabled, ErrorController } = require('@librechat/api');
|
||||
const { connectDb, indexSync } = require('~/db');
|
||||
|
||||
const validateImageRequest = require('./middleware/validateImageRequest');
|
||||
const { jwtLogin, ldapLogin, passportLogin } = require('~/strategies');
|
||||
const errorController = require('./controllers/ErrorController');
|
||||
const initializeMCPs = require('./services/initializeMCPs');
|
||||
const configureSocialLogins = require('./socialLogins');
|
||||
const AppService = require('./services/AppService');
|
||||
|
@ -120,8 +118,7 @@ const startServer = async () => {
|
|||
app.use('/api/tags', routes.tags);
|
||||
app.use('/api/mcp', routes.mcp);
|
||||
|
||||
// Add the error controller one more time after all routes
|
||||
app.use(errorController);
|
||||
app.use(ErrorController);
|
||||
|
||||
app.use((req, res) => {
|
||||
res.set({
|
||||
|
|
|
@ -92,7 +92,7 @@ async function healthCheckPoll(app, retries = 0) {
|
|||
if (response.status === 200) {
|
||||
return; // App is healthy
|
||||
}
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// Ignore connection errors during polling
|
||||
}
|
||||
|
||||
|
|
|
@ -1,7 +1,10 @@
|
|||
// file deepcode ignore NoRateLimitingForLogin: Rate limiting is handled by the `loginLimiter` middleware
|
||||
const express = require('express');
|
||||
const passport = require('passport');
|
||||
const { isEnabled } = require('@librechat/api');
|
||||
const { randomState } = require('openid-client');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { ErrorTypes } = require('librechat-data-provider');
|
||||
const {
|
||||
checkBan,
|
||||
logHeaders,
|
||||
|
@ -10,8 +13,6 @@ const {
|
|||
checkDomainAllowed,
|
||||
} = require('~/server/middleware');
|
||||
const { setAuthTokens, setOpenIDAuthTokens } = require('~/server/services/AuthService');
|
||||
const { isEnabled } = require('~/server/utils');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
|
@ -46,13 +47,13 @@ const oauthHandler = async (req, res) => {
|
|||
};
|
||||
|
||||
router.get('/error', (req, res) => {
|
||||
// A single error message is pushed by passport when authentication fails.
|
||||
/** A single error message is pushed by passport when authentication fails. */
|
||||
const errorMessage = req.session?.messages?.pop() || 'Unknown error';
|
||||
logger.error('Error in OAuth authentication:', {
|
||||
message: req.session?.messages?.pop() || 'Unknown error',
|
||||
message: errorMessage,
|
||||
});
|
||||
|
||||
// Redirect to login page with auth_failed parameter to prevent infinite redirect loops
|
||||
res.redirect(`${domains.client}/login?redirect=false`);
|
||||
res.redirect(`${domains.client}/login?redirect=false&error=${ErrorTypes.AUTH_FAILED}`);
|
||||
});
|
||||
|
||||
/**
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
const fs = require('fs');
|
||||
const { isEnabled } = require('@librechat/api');
|
||||
const LdapStrategy = require('passport-ldapauth');
|
||||
const { SystemRoles } = require('librechat-data-provider');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { SystemRoles, ErrorTypes } = require('librechat-data-provider');
|
||||
const { createUser, findUser, updateUser, countUsers } = require('~/models');
|
||||
const { getBalanceConfig } = require('~/server/services/Config');
|
||||
const { isEnabled } = require('~/server/utils');
|
||||
|
||||
const {
|
||||
LDAP_URL,
|
||||
|
@ -90,6 +90,14 @@ const ldapLogin = new LdapStrategy(ldapOptions, async (userinfo, done) => {
|
|||
(LDAP_ID && userinfo[LDAP_ID]) || userinfo.uid || userinfo.sAMAccountName || userinfo.mail;
|
||||
|
||||
let user = await findUser({ ldapId });
|
||||
if (user && user.provider !== 'ldap') {
|
||||
logger.info(
|
||||
`[ldapStrategy] User ${user.email} already exists with provider ${user.provider}`,
|
||||
);
|
||||
return done(null, false, {
|
||||
message: ErrorTypes.AUTH_FAILED,
|
||||
});
|
||||
}
|
||||
|
||||
const fullNameAttributes = LDAP_FULL_NAME && LDAP_FULL_NAME.split(',');
|
||||
const fullName =
|
||||
|
|
|
@ -3,9 +3,9 @@ const fetch = require('node-fetch');
|
|||
const passport = require('passport');
|
||||
const client = require('openid-client');
|
||||
const jwtDecode = require('jsonwebtoken/decode');
|
||||
const { CacheKeys } = require('librechat-data-provider');
|
||||
const { HttpsProxyAgent } = require('https-proxy-agent');
|
||||
const { hashToken, logger } = require('@librechat/data-schemas');
|
||||
const { CacheKeys, ErrorTypes } = require('librechat-data-provider');
|
||||
const { Strategy: OpenIDStrategy } = require('openid-client/passport');
|
||||
const { isEnabled, safeStringify, logHeaders } = require('@librechat/api');
|
||||
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
||||
|
@ -320,6 +320,14 @@ async function setupOpenId() {
|
|||
} for openidId: ${claims.sub}`,
|
||||
);
|
||||
}
|
||||
if (user != null && user.provider !== 'openid') {
|
||||
logger.info(
|
||||
`[openidStrategy] Attempted OpenID login by user ${user.email}, was registered with "${user.provider}" provider`,
|
||||
);
|
||||
return done(null, false, {
|
||||
message: ErrorTypes.AUTH_FAILED,
|
||||
});
|
||||
}
|
||||
const userinfo = {
|
||||
...claims,
|
||||
...(await getUserInfo(openidConfig, tokenset.access_token, claims.sub)),
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
const fetch = require('node-fetch');
|
||||
const jwtDecode = require('jsonwebtoken/decode');
|
||||
const { setupOpenId } = require('./openidStrategy');
|
||||
const { ErrorTypes } = require('librechat-data-provider');
|
||||
const { findUser, createUser, updateUser } = require('~/models');
|
||||
const { setupOpenId } = require('./openidStrategy');
|
||||
|
||||
// --- Mocks ---
|
||||
jest.mock('node-fetch');
|
||||
|
@ -50,7 +51,7 @@ jest.mock('openid-client', () => {
|
|||
issuer: 'https://fake-issuer.com',
|
||||
// Add any other properties needed by the implementation
|
||||
}),
|
||||
fetchUserInfo: jest.fn().mockImplementation((config, accessToken, sub) => {
|
||||
fetchUserInfo: jest.fn().mockImplementation(() => {
|
||||
// Only return additional properties, but don't override any claims
|
||||
return Promise.resolve({});
|
||||
}),
|
||||
|
@ -261,17 +262,20 @@ describe('setupOpenId', () => {
|
|||
});
|
||||
|
||||
it('should update an existing user on login', async () => {
|
||||
// Arrange – simulate that a user already exists
|
||||
// Arrange – simulate that a user already exists with openid provider
|
||||
const existingUser = {
|
||||
_id: 'existingUserId',
|
||||
provider: 'local',
|
||||
provider: 'openid',
|
||||
email: tokenset.claims().email,
|
||||
openidId: '',
|
||||
username: '',
|
||||
name: '',
|
||||
};
|
||||
findUser.mockImplementation(async (query) => {
|
||||
if (query.openidId === tokenset.claims().sub || query.email === tokenset.claims().email) {
|
||||
if (
|
||||
query.openidId === tokenset.claims().sub ||
|
||||
(query.email === tokenset.claims().email && query.provider === 'openid')
|
||||
) {
|
||||
return existingUser;
|
||||
}
|
||||
return null;
|
||||
|
@ -294,12 +298,38 @@ describe('setupOpenId', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('should block login when email exists with different provider', async () => {
|
||||
// Arrange – simulate that a user exists with same email but different provider
|
||||
const existingUser = {
|
||||
_id: 'existingUserId',
|
||||
provider: 'google',
|
||||
email: tokenset.claims().email,
|
||||
googleId: 'some-google-id',
|
||||
username: 'existinguser',
|
||||
name: 'Existing User',
|
||||
};
|
||||
findUser.mockImplementation(async (query) => {
|
||||
if (query.email === tokenset.claims().email && !query.provider) {
|
||||
return existingUser;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await validate(tokenset);
|
||||
|
||||
// Assert – verify that the strategy rejects login
|
||||
expect(result.user).toBe(false);
|
||||
expect(result.details.message).toBe(ErrorTypes.AUTH_FAILED);
|
||||
expect(createUser).not.toHaveBeenCalled();
|
||||
expect(updateUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should enforce the required role and reject login if missing', async () => {
|
||||
// Arrange – simulate a token without the required role.
|
||||
jwtDecode.mockReturnValue({
|
||||
roles: ['SomeOtherRole'],
|
||||
});
|
||||
const userinfo = tokenset.claims();
|
||||
|
||||
// Act
|
||||
const { user, details } = await validate(tokenset);
|
||||
|
@ -310,9 +340,6 @@ describe('setupOpenId', () => {
|
|||
});
|
||||
|
||||
it('should attempt to download and save the avatar if picture is provided', async () => {
|
||||
// Arrange – ensure userinfo contains a picture URL
|
||||
const userinfo = tokenset.claims();
|
||||
|
||||
// Act
|
||||
const { user } = await validate(tokenset);
|
||||
|
||||
|
|
|
@ -2,6 +2,7 @@ const fs = require('fs');
|
|||
const path = require('path');
|
||||
const fetch = require('node-fetch');
|
||||
const passport = require('passport');
|
||||
const { ErrorTypes } = require('librechat-data-provider');
|
||||
const { hashToken, logger } = require('@librechat/data-schemas');
|
||||
const { Strategy: SamlStrategy } = require('@node-saml/passport-saml');
|
||||
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
||||
|
@ -203,6 +204,15 @@ async function setupSaml() {
|
|||
);
|
||||
}
|
||||
|
||||
if (user && user.provider !== 'saml') {
|
||||
logger.info(
|
||||
`[samlStrategy] User ${user.email} already exists with provider ${user.provider}`,
|
||||
);
|
||||
return done(null, false, {
|
||||
message: ErrorTypes.AUTH_FAILED,
|
||||
});
|
||||
}
|
||||
|
||||
const fullName = getFullName(profile);
|
||||
|
||||
const username = convertToUsername(
|
||||
|
|
|
@ -378,11 +378,11 @@ u7wlOSk+oFzDIO/UILIA
|
|||
});
|
||||
|
||||
it('should update an existing user on login', async () => {
|
||||
// Set up findUser to return an existing user
|
||||
// Set up findUser to return an existing user with saml provider
|
||||
const { findUser } = require('~/models');
|
||||
const existingUser = {
|
||||
_id: 'existing-user-id',
|
||||
provider: 'local',
|
||||
provider: 'saml',
|
||||
email: baseProfile.email,
|
||||
samlId: '',
|
||||
username: 'oldusername',
|
||||
|
@ -400,6 +400,26 @@ u7wlOSk+oFzDIO/UILIA
|
|||
expect(user.email).toBe(baseProfile.email);
|
||||
});
|
||||
|
||||
it('should block login when email exists with different provider', async () => {
|
||||
// Set up findUser to return a user with different provider
|
||||
const { findUser } = require('~/models');
|
||||
const existingUser = {
|
||||
_id: 'existing-user-id',
|
||||
provider: 'google',
|
||||
email: baseProfile.email,
|
||||
googleId: 'some-google-id',
|
||||
username: 'existinguser',
|
||||
name: 'Existing User',
|
||||
};
|
||||
findUser.mockResolvedValue(existingUser);
|
||||
|
||||
const profile = { ...baseProfile };
|
||||
const result = await validate(profile);
|
||||
|
||||
expect(result.user).toBe(false);
|
||||
expect(result.details.message).toBe(require('librechat-data-provider').ErrorTypes.AUTH_FAILED);
|
||||
});
|
||||
|
||||
it('should attempt to download and save the avatar if picture is provided', async () => {
|
||||
const profile = { ...baseProfile };
|
||||
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
const { isEnabled } = require('@librechat/api');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { ErrorTypes } = require('librechat-data-provider');
|
||||
const { createSocialUser, handleExistingUser } = require('./process');
|
||||
const { isEnabled } = require('~/server/utils');
|
||||
const { findUser } = require('~/models');
|
||||
|
||||
const socialLogin =
|
||||
|
@ -11,12 +12,20 @@ const socialLogin =
|
|||
profile,
|
||||
});
|
||||
|
||||
const oldUser = await findUser({ email: email.trim() });
|
||||
const existingUser = await findUser({ email: email.trim() });
|
||||
const ALLOW_SOCIAL_REGISTRATION = isEnabled(process.env.ALLOW_SOCIAL_REGISTRATION);
|
||||
|
||||
if (oldUser) {
|
||||
await handleExistingUser(oldUser, avatarUrl);
|
||||
return cb(null, oldUser);
|
||||
if (existingUser?.provider === provider) {
|
||||
await handleExistingUser(existingUser, avatarUrl);
|
||||
return cb(null, existingUser);
|
||||
} else if (existingUser) {
|
||||
logger.info(
|
||||
`[${provider}Login] User ${email} already exists with provider ${existingUser.provider}`,
|
||||
);
|
||||
const error = new Error(ErrorTypes.AUTH_FAILED);
|
||||
error.code = ErrorTypes.AUTH_FAILED;
|
||||
error.provider = existingUser.provider;
|
||||
return cb(error);
|
||||
}
|
||||
|
||||
if (ALLOW_SOCIAL_REGISTRATION) {
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { useOutletContext, useSearchParams } from 'react-router-dom';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { OpenIDIcon } from '@librechat/client';
|
||||
import { ErrorTypes } from 'librechat-data-provider';
|
||||
import { OpenIDIcon, useToastContext } from '@librechat/client';
|
||||
import { useOutletContext, useSearchParams } from 'react-router-dom';
|
||||
import type { TLoginLayoutContext } from '~/common';
|
||||
import { ErrorMessage } from '~/components/Auth/ErrorMessage';
|
||||
import SocialButton from '~/components/Auth/SocialButton';
|
||||
|
@ -11,6 +12,7 @@ import LoginForm from './LoginForm';
|
|||
|
||||
function Login() {
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
const { error, setError, login } = useAuthContext();
|
||||
const { startupConfig } = useOutletContext<TLoginLayoutContext>();
|
||||
|
||||
|
@ -21,6 +23,19 @@ function Login() {
|
|||
// Persist the disable flag locally so that once detected, auto-redirect stays disabled.
|
||||
const [isAutoRedirectDisabled, setIsAutoRedirectDisabled] = useState(disableAutoRedirect);
|
||||
|
||||
useEffect(() => {
|
||||
const oauthError = searchParams?.get('error');
|
||||
if (oauthError && oauthError === ErrorTypes.AUTH_FAILED) {
|
||||
showToast({
|
||||
message: localize('com_auth_error_oauth_failed'),
|
||||
status: 'error',
|
||||
});
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
newParams.delete('error');
|
||||
setSearchParams(newParams, { replace: true });
|
||||
}
|
||||
}, [searchParams, setSearchParams, showToast, localize]);
|
||||
|
||||
// Once the disable flag is detected, update local state and remove the parameter from the URL.
|
||||
useEffect(() => {
|
||||
if (disableAutoRedirect) {
|
||||
|
|
|
@ -107,6 +107,7 @@
|
|||
"com_auth_error_login_rl": "Too many login attempts in a short amount of time. Please try again later.",
|
||||
"com_auth_error_login_server": "There was an internal server error. Please wait a few moments and try again.",
|
||||
"com_auth_error_login_unverified": "Your account has not been verified. Please check your email for a verification link.",
|
||||
"com_auth_error_oauth_failed": "Authentication failed. Please check your login method and try again.",
|
||||
"com_auth_facebook_login": "Continue with Facebook",
|
||||
"com_auth_full_name": "Full name",
|
||||
"com_auth_github_login": "Continue with Github",
|
||||
|
|
|
@ -1,36 +1,43 @@
|
|||
const errorController = require('./ErrorController');
|
||||
const { logger } = require('~/config');
|
||||
import { logger } from '@librechat/data-schemas';
|
||||
import { ErrorController } from './error';
|
||||
import type { Request, Response } from 'express';
|
||||
import type { ValidationError, MongoServerError, CustomError } from '~/types';
|
||||
|
||||
// Mock the logger
|
||||
jest.mock('~/config', () => ({
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
...jest.requireActual('@librechat/data-schemas'),
|
||||
logger: {
|
||||
error: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('ErrorController', () => {
|
||||
let mockReq, mockRes, mockNext;
|
||||
let mockReq: Request;
|
||||
let mockRes: Response;
|
||||
|
||||
beforeEach(() => {
|
||||
mockReq = {};
|
||||
mockReq = {
|
||||
originalUrl: '',
|
||||
} as Request;
|
||||
mockRes = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
send: jest.fn(),
|
||||
};
|
||||
mockNext = jest.fn();
|
||||
logger.error.mockClear();
|
||||
} as unknown as Response;
|
||||
(logger.error as jest.Mock).mockClear();
|
||||
});
|
||||
|
||||
describe('ValidationError handling', () => {
|
||||
it('should handle ValidationError with single error', () => {
|
||||
const validationError = {
|
||||
name: 'ValidationError',
|
||||
message: 'Validation error',
|
||||
errors: {
|
||||
email: { message: 'Email is required', path: 'email' },
|
||||
},
|
||||
};
|
||||
} as ValidationError;
|
||||
|
||||
errorController(validationError, mockReq, mockRes, mockNext);
|
||||
ErrorController(validationError, mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(400);
|
||||
expect(mockRes.send).toHaveBeenCalledWith({
|
||||
|
@ -43,13 +50,14 @@ describe('ErrorController', () => {
|
|||
it('should handle ValidationError with multiple errors', () => {
|
||||
const validationError = {
|
||||
name: 'ValidationError',
|
||||
message: 'Validation error',
|
||||
errors: {
|
||||
email: { message: 'Email is required', path: 'email' },
|
||||
password: { message: 'Password is required', path: 'password' },
|
||||
},
|
||||
};
|
||||
} as ValidationError;
|
||||
|
||||
errorController(validationError, mockReq, mockRes, mockNext);
|
||||
ErrorController(validationError, mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(400);
|
||||
expect(mockRes.send).toHaveBeenCalledWith({
|
||||
|
@ -63,9 +71,9 @@ describe('ErrorController', () => {
|
|||
const validationError = {
|
||||
name: 'ValidationError',
|
||||
errors: {},
|
||||
};
|
||||
} as ValidationError;
|
||||
|
||||
errorController(validationError, mockReq, mockRes, mockNext);
|
||||
ErrorController(validationError, mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(400);
|
||||
expect(mockRes.send).toHaveBeenCalledWith({
|
||||
|
@ -78,43 +86,59 @@ describe('ErrorController', () => {
|
|||
describe('Duplicate key error handling', () => {
|
||||
it('should handle duplicate key error (code 11000)', () => {
|
||||
const duplicateKeyError = {
|
||||
name: 'MongoServerError',
|
||||
message: 'Duplicate key error',
|
||||
code: 11000,
|
||||
keyValue: { email: 'test@example.com' },
|
||||
};
|
||||
errmsg:
|
||||
'E11000 duplicate key error collection: test.users index: email_1 dup key: { email: "test@example.com" }',
|
||||
} as MongoServerError;
|
||||
|
||||
errorController(duplicateKeyError, mockReq, mockRes, mockNext);
|
||||
ErrorController(duplicateKeyError, mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(409);
|
||||
expect(mockRes.send).toHaveBeenCalledWith({
|
||||
messages: 'An document with that ["email"] already exists.',
|
||||
fields: '["email"]',
|
||||
});
|
||||
expect(logger.error).toHaveBeenCalledWith('Duplicate key error:', duplicateKeyError.keyValue);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'Duplicate key error: E11000 duplicate key error collection: test.users index: email_1 dup key: { email: "test@example.com" }',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle duplicate key error with multiple fields', () => {
|
||||
const duplicateKeyError = {
|
||||
name: 'MongoServerError',
|
||||
message: 'Duplicate key error',
|
||||
code: 11000,
|
||||
keyValue: { email: 'test@example.com', username: 'testuser' },
|
||||
};
|
||||
errmsg:
|
||||
'E11000 duplicate key error collection: test.users index: email_1 dup key: { email: "test@example.com" }',
|
||||
} as MongoServerError;
|
||||
|
||||
errorController(duplicateKeyError, mockReq, mockRes, mockNext);
|
||||
ErrorController(duplicateKeyError, mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(409);
|
||||
expect(mockRes.send).toHaveBeenCalledWith({
|
||||
messages: 'An document with that ["email","username"] already exists.',
|
||||
fields: '["email","username"]',
|
||||
});
|
||||
expect(logger.error).toHaveBeenCalledWith('Duplicate key error:', duplicateKeyError.keyValue);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'Duplicate key error: E11000 duplicate key error collection: test.users index: email_1 dup key: { email: "test@example.com" }',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle error with code 11000 as string', () => {
|
||||
const duplicateKeyError = {
|
||||
code: '11000',
|
||||
name: 'MongoServerError',
|
||||
message: 'Duplicate key error',
|
||||
code: 11000,
|
||||
keyValue: { email: 'test@example.com' },
|
||||
};
|
||||
errmsg:
|
||||
'E11000 duplicate key error collection: test.users index: email_1 dup key: { email: "test@example.com" }',
|
||||
} as MongoServerError;
|
||||
|
||||
errorController(duplicateKeyError, mockReq, mockRes, mockNext);
|
||||
ErrorController(duplicateKeyError, mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(409);
|
||||
expect(mockRes.send).toHaveBeenCalledWith({
|
||||
|
@ -129,9 +153,9 @@ describe('ErrorController', () => {
|
|||
const syntaxError = {
|
||||
statusCode: 400,
|
||||
body: 'Invalid JSON syntax',
|
||||
};
|
||||
} as CustomError;
|
||||
|
||||
errorController(syntaxError, mockReq, mockRes, mockNext);
|
||||
ErrorController(syntaxError, mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(400);
|
||||
expect(mockRes.send).toHaveBeenCalledWith('Invalid JSON syntax');
|
||||
|
@ -141,9 +165,9 @@ describe('ErrorController', () => {
|
|||
const customError = {
|
||||
statusCode: 422,
|
||||
body: { error: 'Unprocessable entity' },
|
||||
};
|
||||
} as CustomError;
|
||||
|
||||
errorController(customError, mockReq, mockRes, mockNext);
|
||||
ErrorController(customError, mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(422);
|
||||
expect(mockRes.send).toHaveBeenCalledWith({ error: 'Unprocessable entity' });
|
||||
|
@ -152,9 +176,9 @@ describe('ErrorController', () => {
|
|||
it('should handle error with statusCode but no body', () => {
|
||||
const partialError = {
|
||||
statusCode: 400,
|
||||
};
|
||||
} as CustomError;
|
||||
|
||||
errorController(partialError, mockReq, mockRes, mockNext);
|
||||
ErrorController(partialError, mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(500);
|
||||
expect(mockRes.send).toHaveBeenCalledWith('An unknown error occurred.');
|
||||
|
@ -163,9 +187,9 @@ describe('ErrorController', () => {
|
|||
it('should handle error with body but no statusCode', () => {
|
||||
const partialError = {
|
||||
body: 'Some error message',
|
||||
};
|
||||
} as CustomError;
|
||||
|
||||
errorController(partialError, mockReq, mockRes, mockNext);
|
||||
ErrorController(partialError, mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(500);
|
||||
expect(mockRes.send).toHaveBeenCalledWith('An unknown error occurred.');
|
||||
|
@ -176,7 +200,7 @@ describe('ErrorController', () => {
|
|||
it('should handle unknown errors', () => {
|
||||
const unknownError = new Error('Some unknown error');
|
||||
|
||||
errorController(unknownError, mockReq, mockRes, mockNext);
|
||||
ErrorController(unknownError, mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(500);
|
||||
expect(mockRes.send).toHaveBeenCalledWith('An unknown error occurred.');
|
||||
|
@ -187,32 +211,31 @@ describe('ErrorController', () => {
|
|||
const mongoError = {
|
||||
code: 11100,
|
||||
message: 'Some MongoDB error',
|
||||
};
|
||||
} as MongoServerError;
|
||||
|
||||
errorController(mongoError, mockReq, mockRes, mockNext);
|
||||
ErrorController(mongoError, mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(500);
|
||||
expect(mockRes.send).toHaveBeenCalledWith('An unknown error occurred.');
|
||||
expect(logger.error).toHaveBeenCalledWith('ErrorController => error', mongoError);
|
||||
});
|
||||
|
||||
it('should handle null/undefined errors', () => {
|
||||
errorController(null, mockReq, mockRes, mockNext);
|
||||
it('should handle generic errors', () => {
|
||||
const genericError = new Error('Test error');
|
||||
|
||||
ErrorController(genericError, mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(500);
|
||||
expect(mockRes.send).toHaveBeenCalledWith('Processing error in ErrorController.');
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
'ErrorController => processing error',
|
||||
expect.any(Error),
|
||||
);
|
||||
expect(mockRes.send).toHaveBeenCalledWith('An unknown error occurred.');
|
||||
expect(logger.error).toHaveBeenCalledWith('ErrorController => error', genericError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Catch block handling', () => {
|
||||
beforeEach(() => {
|
||||
// Restore logger mock to normal behavior for these tests
|
||||
logger.error.mockRestore();
|
||||
logger.error = jest.fn();
|
||||
(logger.error as jest.Mock).mockRestore();
|
||||
(logger.error as jest.Mock) = jest.fn();
|
||||
});
|
||||
|
||||
it('should handle errors when logger.error throws', () => {
|
||||
|
@ -220,10 +243,10 @@ describe('ErrorController', () => {
|
|||
const freshMockRes = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
send: jest.fn(),
|
||||
};
|
||||
} as unknown as Response;
|
||||
|
||||
// Mock logger to throw on the first call, succeed on the second
|
||||
logger.error
|
||||
(logger.error as jest.Mock)
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error('Logger error');
|
||||
})
|
||||
|
@ -231,7 +254,7 @@ describe('ErrorController', () => {
|
|||
|
||||
const testError = new Error('Test error');
|
||||
|
||||
errorController(testError, mockReq, freshMockRes, mockNext);
|
||||
ErrorController(testError, mockReq, freshMockRes);
|
||||
|
||||
expect(freshMockRes.status).toHaveBeenCalledWith(500);
|
||||
expect(freshMockRes.send).toHaveBeenCalledWith('Processing error in ErrorController.');
|
83
packages/api/src/middleware/error.ts
Normal file
83
packages/api/src/middleware/error.ts
Normal file
|
@ -0,0 +1,83 @@
|
|||
import { logger } from '@librechat/data-schemas';
|
||||
import { ErrorTypes } from 'librechat-data-provider';
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import type { MongoServerError, ValidationError, CustomError } from '~/types';
|
||||
|
||||
const handleDuplicateKeyError = (err: MongoServerError, res: Response) => {
|
||||
logger.warn('Duplicate key error: ' + (err.errmsg || err.message));
|
||||
const field = err.keyValue ? `${JSON.stringify(Object.keys(err.keyValue))}` : 'unknown';
|
||||
const code = 409;
|
||||
res
|
||||
.status(code)
|
||||
.send({ messages: `An document with that ${field} already exists.`, fields: field });
|
||||
};
|
||||
|
||||
const handleValidationError = (err: ValidationError, res: Response) => {
|
||||
logger.error('Validation error:', err.errors);
|
||||
const errorMessages = Object.values(err.errors).map((el) => el.message);
|
||||
const fields = `${JSON.stringify(Object.values(err.errors).map((el) => el.path))}`;
|
||||
const code = 400;
|
||||
const messages =
|
||||
errorMessages.length > 1
|
||||
? `${JSON.stringify(errorMessages.join(' '))}`
|
||||
: `${JSON.stringify(errorMessages)}`;
|
||||
|
||||
res.status(code).send({ messages, fields });
|
||||
};
|
||||
|
||||
/** Type guard for ValidationError */
|
||||
function isValidationError(err: unknown): err is ValidationError {
|
||||
return err !== null && typeof err === 'object' && 'name' in err && err.name === 'ValidationError';
|
||||
}
|
||||
|
||||
/** Type guard for MongoServerError (duplicate key) */
|
||||
function isMongoServerError(err: unknown): err is MongoServerError {
|
||||
return err !== null && typeof err === 'object' && 'code' in err && err.code === 11000;
|
||||
}
|
||||
|
||||
/** Type guard for CustomError with statusCode and body */
|
||||
function isCustomError(err: unknown): err is CustomError {
|
||||
return err !== null && typeof err === 'object' && 'statusCode' in err && 'body' in err;
|
||||
}
|
||||
|
||||
export const ErrorController = (
|
||||
err: Error | CustomError,
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
): Response | void => {
|
||||
try {
|
||||
if (!err) {
|
||||
return next();
|
||||
}
|
||||
const error = err as CustomError;
|
||||
|
||||
if (
|
||||
(error.message === ErrorTypes.AUTH_FAILED || error.code === ErrorTypes.AUTH_FAILED) &&
|
||||
req.originalUrl &&
|
||||
req.originalUrl.includes('/oauth/') &&
|
||||
req.originalUrl.includes('/callback')
|
||||
) {
|
||||
const domain = process.env.DOMAIN_CLIENT || 'http://localhost:3080';
|
||||
return res.redirect(`${domain}/login?redirect=false&error=${ErrorTypes.AUTH_FAILED}`);
|
||||
}
|
||||
|
||||
if (isValidationError(error)) {
|
||||
return handleValidationError(error, res);
|
||||
}
|
||||
|
||||
if (isMongoServerError(error)) {
|
||||
return handleDuplicateKeyError(error, res);
|
||||
}
|
||||
|
||||
if (isCustomError(error) && error.statusCode && error.body) {
|
||||
return res.status(error.statusCode).send(error.body);
|
||||
}
|
||||
|
||||
logger.error('ErrorController => error', err);
|
||||
return res.status(500).send('An unknown error occurred.');
|
||||
} catch (processingError) {
|
||||
logger.error('ErrorController => processing error', processingError);
|
||||
return res.status(500).send('Processing error in ErrorController.');
|
||||
}
|
||||
};
|
|
@ -1 +1,2 @@
|
|||
export * from './access';
|
||||
export * from './error';
|
||||
|
|
27
packages/api/src/types/error.ts
Normal file
27
packages/api/src/types/error.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
import type { Error as MongooseError } from 'mongoose';
|
||||
|
||||
/** MongoDB duplicate key error interface */
|
||||
export interface MongoServerError extends Error {
|
||||
code: number;
|
||||
keyValue?: Record<string, unknown>;
|
||||
errmsg?: string;
|
||||
}
|
||||
|
||||
/** Mongoose validation error interface */
|
||||
export interface ValidationError extends MongooseError {
|
||||
name: 'ValidationError';
|
||||
errors: Record<
|
||||
string,
|
||||
{
|
||||
message: string;
|
||||
path?: string;
|
||||
}
|
||||
>;
|
||||
}
|
||||
|
||||
/** Custom error with status code and body */
|
||||
export interface CustomError extends Error {
|
||||
statusCode?: number;
|
||||
body?: unknown;
|
||||
code?: string | number;
|
||||
}
|
|
@ -1,5 +1,6 @@
|
|||
export * from './azure';
|
||||
export * from './events';
|
||||
export * from './error';
|
||||
export * from './google';
|
||||
export * from './mistral';
|
||||
export * from './openai';
|
||||
|
|
|
@ -1359,6 +1359,10 @@ export enum ErrorTypes {
|
|||
* Endpoint models not loaded
|
||||
*/
|
||||
ENDPOINT_MODELS_NOT_LOADED = 'endpoint_models_not_loaded',
|
||||
/**
|
||||
* Generic Authentication failure
|
||||
*/
|
||||
AUTH_FAILED = 'auth_failed',
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue