mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-16 16:30:15 +01: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
264
packages/api/src/middleware/error.spec.ts
Normal file
264
packages/api/src/middleware/error.spec.ts
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
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('@librechat/data-schemas', () => ({
|
||||
...jest.requireActual('@librechat/data-schemas'),
|
||||
logger: {
|
||||
error: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('ErrorController', () => {
|
||||
let mockReq: Request;
|
||||
let mockRes: Response;
|
||||
|
||||
beforeEach(() => {
|
||||
mockReq = {
|
||||
originalUrl: '',
|
||||
} as Request;
|
||||
mockRes = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
send: jest.fn(),
|
||||
} 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);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(400);
|
||||
expect(mockRes.send).toHaveBeenCalledWith({
|
||||
messages: '["Email is required"]',
|
||||
fields: '["email"]',
|
||||
});
|
||||
expect(logger.error).toHaveBeenCalledWith('Validation error:', validationError.errors);
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(400);
|
||||
expect(mockRes.send).toHaveBeenCalledWith({
|
||||
messages: '"Email is required Password is required"',
|
||||
fields: '["email","password"]',
|
||||
});
|
||||
expect(logger.error).toHaveBeenCalledWith('Validation error:', validationError.errors);
|
||||
});
|
||||
|
||||
it('should handle ValidationError with empty errors object', () => {
|
||||
const validationError = {
|
||||
name: 'ValidationError',
|
||||
errors: {},
|
||||
} as ValidationError;
|
||||
|
||||
ErrorController(validationError, mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(400);
|
||||
expect(mockRes.send).toHaveBeenCalledWith({
|
||||
messages: '[]',
|
||||
fields: '[]',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(409);
|
||||
expect(mockRes.send).toHaveBeenCalledWith({
|
||||
messages: 'An document with that ["email"] already exists.',
|
||||
fields: '["email"]',
|
||||
});
|
||||
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);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(409);
|
||||
expect(mockRes.send).toHaveBeenCalledWith({
|
||||
messages: 'An document with that ["email","username"] already exists.',
|
||||
fields: '["email","username"]',
|
||||
});
|
||||
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 = {
|
||||
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);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(409);
|
||||
expect(mockRes.send).toHaveBeenCalledWith({
|
||||
messages: 'An document with that ["email"] already exists.',
|
||||
fields: '["email"]',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('SyntaxError handling', () => {
|
||||
it('should handle errors with statusCode and body', () => {
|
||||
const syntaxError = {
|
||||
statusCode: 400,
|
||||
body: 'Invalid JSON syntax',
|
||||
} as CustomError;
|
||||
|
||||
ErrorController(syntaxError, mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(400);
|
||||
expect(mockRes.send).toHaveBeenCalledWith('Invalid JSON syntax');
|
||||
});
|
||||
|
||||
it('should handle errors with different statusCode and body', () => {
|
||||
const customError = {
|
||||
statusCode: 422,
|
||||
body: { error: 'Unprocessable entity' },
|
||||
} as CustomError;
|
||||
|
||||
ErrorController(customError, mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(422);
|
||||
expect(mockRes.send).toHaveBeenCalledWith({ error: 'Unprocessable entity' });
|
||||
});
|
||||
|
||||
it('should handle error with statusCode but no body', () => {
|
||||
const partialError = {
|
||||
statusCode: 400,
|
||||
} as CustomError;
|
||||
|
||||
ErrorController(partialError, mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(500);
|
||||
expect(mockRes.send).toHaveBeenCalledWith('An unknown error occurred.');
|
||||
});
|
||||
|
||||
it('should handle error with body but no statusCode', () => {
|
||||
const partialError = {
|
||||
body: 'Some error message',
|
||||
} as CustomError;
|
||||
|
||||
ErrorController(partialError, mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(500);
|
||||
expect(mockRes.send).toHaveBeenCalledWith('An unknown error occurred.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Unknown error handling', () => {
|
||||
it('should handle unknown errors', () => {
|
||||
const unknownError = new Error('Some unknown error');
|
||||
|
||||
ErrorController(unknownError, mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(500);
|
||||
expect(mockRes.send).toHaveBeenCalledWith('An unknown error occurred.');
|
||||
expect(logger.error).toHaveBeenCalledWith('ErrorController => error', unknownError);
|
||||
});
|
||||
|
||||
it('should handle errors with code other than 11000', () => {
|
||||
const mongoError = {
|
||||
code: 11100,
|
||||
message: 'Some MongoDB error',
|
||||
} as MongoServerError;
|
||||
|
||||
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 generic errors', () => {
|
||||
const genericError = new Error('Test error');
|
||||
|
||||
ErrorController(genericError, mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(500);
|
||||
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 as jest.Mock).mockRestore();
|
||||
(logger.error as jest.Mock) = jest.fn();
|
||||
});
|
||||
|
||||
it('should handle errors when logger.error throws', () => {
|
||||
// Create fresh mocks for this test
|
||||
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 as jest.Mock)
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error('Logger error');
|
||||
})
|
||||
.mockImplementation(() => {});
|
||||
|
||||
const testError = new Error('Test error');
|
||||
|
||||
ErrorController(testError, mockReq, freshMockRes);
|
||||
|
||||
expect(freshMockRes.status).toHaveBeenCalledWith(500);
|
||||
expect(freshMockRes.send).toHaveBeenCalledWith('Processing error in ErrorController.');
|
||||
expect(logger.error).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
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