🔒 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:
Danny Avila 2025-08-11 18:49:34 -04:00
parent 04d74a7e07
commit 1ccac58403
No known key found for this signature in database
GPG key ID: BF31EEB2C5CA0956
18 changed files with 314 additions and 125 deletions

View 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);
});
});
});

View 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.');
}
};

View file

@ -1 +1,2 @@
export * from './access';
export * from './error';

View 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;
}

View file

@ -1,5 +1,6 @@
export * from './azure';
export * from './events';
export * from './error';
export * from './google';
export * from './mistral';
export * from './openai';

View file

@ -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',
}
/**