LibreChat/api/server/services/AuthService.spec.js
2026-05-07 15:39:48 -04:00

542 lines
17 KiB
JavaScript

jest.mock('@librechat/data-schemas', () => ({
logger: { info: jest.fn(), warn: jest.fn(), debug: jest.fn(), error: jest.fn() },
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),
parseCloudFrontCookieScope: jest.fn(() => null),
CLOUDFRONT_SCOPE_COOKIE: 'LibreChat-CloudFront-Scope',
}));
jest.mock('~/models', () => ({
findUser: jest.fn(),
findToken: jest.fn(),
createUser: jest.fn(),
updateUser: jest.fn(),
countUsers: jest.fn(),
getUserById: jest.fn(),
findSession: jest.fn(),
createToken: jest.fn(),
deleteTokens: jest.fn(),
deleteSession: jest.fn(),
createSession: jest.fn(),
generateToken: jest.fn(),
deleteUserById: jest.fn(),
generateRefreshToken: jest.fn(),
}));
jest.mock('~/strategies/validators', () => ({ registerSchema: { parse: jest.fn() } }));
jest.mock('~/server/services/Config', () => ({ getAppConfig: jest.fn() }));
jest.mock('~/server/utils', () => ({ sendEmail: jest.fn() }));
const {
shouldUseSecureCookie,
isEmailDomainAllowed,
resolveAppConfigForUser,
setCloudFrontCookies,
parseCloudFrontCookieScope,
} = require('@librechat/api');
const {
findUser,
getUserById,
generateToken,
generateRefreshToken,
createSession,
} = require('~/models');
const { getAppConfig } = require('~/server/services/Config');
const { setOpenIDAuthTokens, requestPasswordReset, setAuthTokens } = require('./AuthService');
/** Helper to build a mock Express response */
function mockResponse() {
const cookies = {};
const res = {
cookie: jest.fn((name, value, options) => {
cookies[name] = { value, options };
}),
_cookies: cookies,
};
return res;
}
/** Helper to build a mock Express request with session */
function mockRequest(sessionData = {}, cookies = {}) {
return {
session: { openidTokens: null, ...sessionData },
cookies,
};
}
describe('setOpenIDAuthTokens', () => {
const env = process.env;
beforeEach(() => {
jest.clearAllMocks();
process.env = {
...env,
JWT_REFRESH_SECRET: 'test-refresh-secret',
OPENID_REUSE_TOKENS: 'true',
};
});
afterAll(() => {
process.env = env;
});
describe('token selection (id_token vs access_token)', () => {
it('should return id_token when both id_token and access_token are present', () => {
const tokenset = {
id_token: 'the-id-token',
access_token: 'the-access-token',
refresh_token: 'the-refresh-token',
};
const req = mockRequest();
const res = mockResponse();
const result = setOpenIDAuthTokens(tokenset, req, res, 'user-123');
expect(result).toBe('the-id-token');
});
it('should return access_token when id_token is not available', () => {
const tokenset = {
access_token: 'the-access-token',
refresh_token: 'the-refresh-token',
};
const req = mockRequest();
const res = mockResponse();
const result = setOpenIDAuthTokens(tokenset, req, res, 'user-123');
expect(result).toBe('the-access-token');
});
it('should return access_token when id_token is undefined', () => {
const tokenset = {
id_token: undefined,
access_token: 'the-access-token',
refresh_token: 'the-refresh-token',
};
const req = mockRequest();
const res = mockResponse();
const result = setOpenIDAuthTokens(tokenset, req, res, 'user-123');
expect(result).toBe('the-access-token');
});
it('should return access_token when id_token is null', () => {
const tokenset = {
id_token: null,
access_token: 'the-access-token',
refresh_token: 'the-refresh-token',
};
const req = mockRequest();
const res = mockResponse();
const result = setOpenIDAuthTokens(tokenset, req, res, 'user-123');
expect(result).toBe('the-access-token');
});
it('should return id_token even when id_token and access_token differ', () => {
const tokenset = {
id_token: 'id-token-jwt-signed-by-idp',
access_token: 'opaque-graph-api-token',
refresh_token: 'refresh-token',
};
const req = mockRequest();
const res = mockResponse();
const result = setOpenIDAuthTokens(tokenset, req, res, 'user-123');
expect(result).toBe('id-token-jwt-signed-by-idp');
expect(result).not.toBe('opaque-graph-api-token');
});
});
describe('session token storage', () => {
it('should store the original access_token in session (not id_token)', () => {
const tokenset = {
id_token: 'the-id-token',
access_token: 'the-access-token',
refresh_token: 'the-refresh-token',
};
const req = mockRequest();
const res = mockResponse();
setOpenIDAuthTokens(tokenset, req, res, 'user-123');
expect(req.session.openidTokens.accessToken).toBe('the-access-token');
expect(req.session.openidTokens.idToken).toBe('the-id-token');
expect(req.session.openidTokens.refreshToken).toBe('the-refresh-token');
});
it('should preserve the existing session id_token when refresh omits one', () => {
const tokenset = {
access_token: 'new-access-token',
refresh_token: 'new-refresh-token',
};
const req = mockRequest({
openidTokens: {
accessToken: 'old-access-token',
idToken: 'existing-id-token',
refreshToken: 'old-refresh-token',
},
});
const res = mockResponse();
const result = setOpenIDAuthTokens(tokenset, req, res, 'user-123');
expect(result).toBe('new-access-token');
expect(req.session.openidTokens.accessToken).toBe('new-access-token');
expect(req.session.openidTokens.idToken).toBe('existing-id-token');
expect(req.session.openidTokens.refreshToken).toBe('new-refresh-token');
});
});
describe('cookie secure flag', () => {
it('should call shouldUseSecureCookie for every cookie set', () => {
const tokenset = {
id_token: 'the-id-token',
access_token: 'the-access-token',
refresh_token: 'the-refresh-token',
};
const req = mockRequest();
const res = mockResponse();
setOpenIDAuthTokens(tokenset, req, res, 'user-123');
// token_provider + openid_user_id (session path, so no refreshToken/openid_access_token cookies)
const secureCalls = shouldUseSecureCookie.mock.calls.length;
expect(secureCalls).toBeGreaterThanOrEqual(2);
// Verify all cookies use the result of shouldUseSecureCookie
for (const [, cookie] of Object.entries(res._cookies)) {
expect(cookie.options.secure).toBe(false);
}
});
it('should set secure: true when shouldUseSecureCookie returns true', () => {
shouldUseSecureCookie.mockReturnValue(true);
const tokenset = {
id_token: 'the-id-token',
access_token: 'the-access-token',
refresh_token: 'the-refresh-token',
};
const req = mockRequest();
const res = mockResponse();
setOpenIDAuthTokens(tokenset, req, res, 'user-123');
for (const [, cookie] of Object.entries(res._cookies)) {
expect(cookie.options.secure).toBe(true);
}
});
it('should use shouldUseSecureCookie for cookie fallback path (no session)', () => {
shouldUseSecureCookie.mockReturnValue(false);
const tokenset = {
id_token: 'the-id-token',
access_token: 'the-access-token',
refresh_token: 'the-refresh-token',
};
const req = { session: null };
const res = mockResponse();
setOpenIDAuthTokens(tokenset, req, res, 'user-123');
// In the cookie fallback path, we get: refreshToken, openid_access_token, token_provider, openid_user_id
expect(res.cookie).toHaveBeenCalledWith(
'refreshToken',
expect.any(String),
expect.objectContaining({ secure: false }),
);
expect(res.cookie).toHaveBeenCalledWith(
'openid_access_token',
expect.any(String),
expect.objectContaining({ secure: false }),
);
expect(res.cookie).toHaveBeenCalledWith(
'token_provider',
'openid',
expect.objectContaining({ secure: false }),
);
});
});
describe('edge cases', () => {
it('should return undefined when tokenset is null', () => {
const req = mockRequest();
const res = mockResponse();
const result = setOpenIDAuthTokens(null, req, res, 'user-123');
expect(result).toBeUndefined();
});
it('should return undefined when access_token is missing', () => {
const tokenset = { refresh_token: 'refresh' };
const req = mockRequest();
const res = mockResponse();
const result = setOpenIDAuthTokens(tokenset, req, res, 'user-123');
expect(result).toBeUndefined();
});
it('should return undefined when no refresh token is available', () => {
const tokenset = { access_token: 'access', id_token: 'id' };
const req = mockRequest();
const res = mockResponse();
const result = setOpenIDAuthTokens(tokenset, req, res, 'user-123');
expect(result).toBeUndefined();
});
it('should use existingRefreshToken when tokenset has no refresh_token', () => {
const tokenset = {
id_token: 'the-id-token',
access_token: 'the-access-token',
};
const req = mockRequest();
const res = mockResponse();
const result = setOpenIDAuthTokens(tokenset, req, res, 'user-123', 'existing-refresh');
expect(result).toBe('the-id-token');
expect(req.session.openidTokens.refreshToken).toBe('existing-refresh');
});
});
});
describe('requestPasswordReset', () => {
beforeEach(() => {
jest.clearAllMocks();
isEmailDomainAllowed.mockReturnValue(true);
getAppConfig.mockResolvedValue({
registration: { allowedDomains: ['example.com'] },
});
resolveAppConfigForUser.mockResolvedValue({
registration: { allowedDomains: ['example.com'] },
});
});
it('should fast-fail with base config before DB lookup for blocked domains', async () => {
isEmailDomainAllowed.mockReturnValue(false);
const req = { body: { email: 'blocked@evil.com' }, ip: '127.0.0.1' };
const result = await requestPasswordReset(req);
expect(getAppConfig).toHaveBeenCalledWith({ baseOnly: true });
expect(findUser).not.toHaveBeenCalled();
expect(result).toBeInstanceOf(Error);
});
it('should call resolveAppConfigForUser for tenant user', async () => {
const user = {
_id: 'user-tenant',
email: 'user@example.com',
tenantId: 'tenant-x',
role: 'USER',
};
findUser.mockResolvedValue(user);
const req = { body: { email: 'user@example.com' }, ip: '127.0.0.1' };
await requestPasswordReset(req);
expect(resolveAppConfigForUser).toHaveBeenCalledWith(getAppConfig, user);
});
it('should reuse baseConfig for non-tenant user without calling resolveAppConfigForUser', async () => {
findUser.mockResolvedValue({ _id: 'user-no-tenant', email: 'user@example.com' });
const req = { body: { email: 'user@example.com' }, ip: '127.0.0.1' };
await requestPasswordReset(req);
expect(resolveAppConfigForUser).not.toHaveBeenCalled();
});
it('should return generic response when tenant config blocks the domain (non-enumerable)', async () => {
const user = {
_id: 'user-tenant',
email: 'user@example.com',
tenantId: 'tenant-x',
role: 'USER',
};
findUser.mockResolvedValue(user);
isEmailDomainAllowed.mockReturnValueOnce(true).mockReturnValueOnce(false);
const req = { body: { email: 'user@example.com' }, ip: '127.0.0.1' };
const result = await requestPasswordReset(req);
expect(result).not.toBeInstanceOf(Error);
expect(result.message).toContain('If an account with that email exists');
});
});
describe('CloudFront cookie integration', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('setOpenIDAuthTokens', () => {
const validTokenset = {
id_token: 'the-id-token',
access_token: 'the-access-token',
refresh_token: 'the-refresh-token',
};
it('calls setCloudFrontCookies with response object and user scope from options', () => {
const req = mockRequest();
const res = mockResponse();
setOpenIDAuthTokens(validTokenset, req, res, {
userId: 'user-123',
tenantId: 'tenantA',
});
expect(setCloudFrontCookies).toHaveBeenCalledWith(
res,
{
userId: 'user-123',
tenantId: 'tenantA',
},
null,
);
});
it('keeps backward compatibility with positional user and tenant params', () => {
const req = mockRequest();
const res = mockResponse();
setOpenIDAuthTokens(validTokenset, req, res, 'user-123', undefined, 'tenantA');
expect(setCloudFrontCookies).toHaveBeenCalledWith(
res,
{
userId: 'user-123',
tenantId: 'tenantA',
},
null,
);
});
it('treats a null options argument as an empty legacy user id', () => {
const req = mockRequest();
const res = mockResponse();
const result = setOpenIDAuthTokens(validTokenset, req, res, null);
expect(result).toBe('the-id-token');
expect(setCloudFrontCookies).toHaveBeenCalledWith(
res,
{
userId: null,
tenantId: undefined,
},
null,
);
});
it('treats omitted options as an empty legacy user id', () => {
const req = mockRequest();
const res = mockResponse();
const result = setOpenIDAuthTokens(validTokenset, req, res);
expect(result).toBe('the-id-token');
expect(setCloudFrontCookies).toHaveBeenCalledWith(
res,
{
userId: null,
tenantId: undefined,
},
null,
);
});
it('treats an object without token option keys as empty options', () => {
const req = mockRequest();
const res = mockResponse();
const result = setOpenIDAuthTokens(validTokenset, req, res, {});
expect(result).toBe('the-id-token');
expect(setCloudFrontCookies).toHaveBeenCalledWith(
res,
{
userId: undefined,
tenantId: undefined,
},
null,
);
});
it('succeeds even when setCloudFrontCookies returns false', () => {
setCloudFrontCookies.mockReturnValue(false);
const req = mockRequest();
const res = mockResponse();
const result = setOpenIDAuthTokens(validTokenset, req, res, 'user-123');
expect(result).toBe('the-id-token');
});
});
describe('setAuthTokens', () => {
beforeEach(() => {
getUserById.mockResolvedValue({ _id: 'user-123', tenantId: 'tenantA' });
generateToken.mockResolvedValue('mock-access-token');
generateRefreshToken.mockReturnValue('mock-refresh-token');
createSession.mockResolvedValue({
session: { expiration: new Date(Date.now() + 604800000) },
refreshToken: 'mock-refresh-token',
});
});
it('calls setCloudFrontCookies with response object and user scope', async () => {
const res = mockResponse();
await setAuthTokens('user-123', res);
expect(setCloudFrontCookies).toHaveBeenCalledWith(
res,
{
userId: 'user-123',
tenantId: 'tenantA',
},
null,
);
});
it('passes the previous CloudFront cookie scope when present', async () => {
parseCloudFrontCookieScope.mockReturnValue({ userId: 'old-user', tenantId: 'old-tenant' });
const res = mockResponse();
const req = mockRequest({}, { 'LibreChat-CloudFront-Scope': 'encoded-scope' });
await setAuthTokens('user-123', res, null, req);
expect(parseCloudFrontCookieScope).toHaveBeenCalledWith('encoded-scope');
expect(setCloudFrontCookies).toHaveBeenCalledWith(
res,
{
userId: 'user-123',
tenantId: 'tenantA',
},
{ userId: 'old-user', tenantId: 'old-tenant' },
);
});
it('succeeds even when setCloudFrontCookies returns false', async () => {
setCloudFrontCookies.mockReturnValue(false);
const res = mockResponse();
const result = await setAuthTokens('user-123', res);
expect(result).toBe('mock-access-token');
});
});
});