mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-11-12 09:45:47 +00:00
Some checks are pending
Backend Unit Tests / Build packages (push) Waiting to run
Backend Unit Tests / Codegraph select (push) Waiting to run
Backend Unit Tests / TypeScript type checks (push) Blocked by required conditions
Backend Unit Tests / Circular dependency checks (push) Waiting to run
Backend Unit Tests / Tests: api (shard 1/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: api (shard 2/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: api (shard 3/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: data-provider (push) Blocked by required conditions
Backend Unit Tests / Tests: data-schemas (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 1/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 2/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 3/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 4/4) (push) Blocked by required conditions
Codegraph E2E Votes / vote (full suite) (push) Waiting to run
Docker Dev Branch Images Build / publish (push) Waiting to run
Frontend Unit Tests / Client build recovery regression (push) Waiting to run
Frontend Unit Tests / Codegraph select (push) Waiting to run
Frontend Unit Tests / Build packages (push) Waiting to run
Frontend Unit Tests / TypeScript type checks (client) (push) Blocked by required conditions
Frontend Unit Tests / Tests: @librechat/client (push) Blocked by required conditions
Frontend Unit Tests / Tests: Ubuntu (shard 1/2) (push) Blocked by required conditions
Frontend Unit Tests / Tests: Ubuntu (shard 2/2) (push) Blocked by required conditions
Frontend Unit Tests / Vite build verification (push) Blocked by required conditions
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
* feat: enforce required two-factor authentication Require local and LDAP accounts to enroll in TOTP before they can use the app when ENFORCE_TWO_FACTOR_AUTHENTICATION is set. Sign-in, refresh, bearer verification, optional share auth and image authorization all route an unenrolled account into a staged setup flow, and every bearer credential minted before enrollment or a password reset is retired. Password reset retirement uses the existing credentialsChangedAt stamp, and commitPasswordReset clears any staged enrollment in the same write. Passkey sign-in follows the same enrollment gate as password sign-in. * test: cover passkey sign-in handing off to required enrollment * refactor: keep Mongoose user types out of two-factor auth contracts * fix: withdraw a minted session when its retirement recheck fails * fix: hold Agent API keys and the reset CLI to the required two-factor policy * fix: adopt a setup token handed off after the setup screen mounted * fix: move every tab that receives an enrollment hand-off into setup * fix: read the enrollment gate's retirement cutoff in system scope * fix: clear minted session cookies before the fallible session deletion * fix: adopt a setup token persisted before the setup screen subscribed
253 lines
9.5 KiB
JavaScript
253 lines
9.5 KiB
JavaScript
const mockVerify = jest.fn();
|
|
const mockGetUserById = jest.fn();
|
|
const mockFindSession = jest.fn();
|
|
const mockRunAsSystem = jest.fn((fn) => fn());
|
|
const mockIsTwoFactorEnrollmentRequired = jest.fn(() => false);
|
|
const mockIsTokenRetired = jest.fn(() => false);
|
|
const mockClearCloudFrontCookies = jest.fn();
|
|
|
|
jest.mock('jsonwebtoken', () => ({ verify: (...args) => mockVerify(...args) }));
|
|
jest.mock('@librechat/api', () => ({
|
|
createOptionalShareFileAuth: jest.requireActual('@librechat/api').createOptionalShareFileAuth,
|
|
isEnabled: (v) => v === 'true' || v === true,
|
|
clearCloudFrontCookies: (...args) => mockClearCloudFrontCookies(...args),
|
|
isTwoFactorEnrollmentRequired: (...args) => mockIsTwoFactorEnrollmentRequired(...args),
|
|
isTokenRetired: (...args) => mockIsTokenRetired(...args),
|
|
}));
|
|
jest.mock('@librechat/data-schemas', () => ({
|
|
...jest.requireActual('@librechat/data-schemas'),
|
|
logger: { warn: jest.fn(), error: jest.fn() },
|
|
runAsSystem: (...args) => mockRunAsSystem(...args),
|
|
}));
|
|
jest.mock('~/models', () => ({
|
|
getUserById: (...args) => mockGetUserById(...args),
|
|
findSession: (...args) => mockFindSession(...args),
|
|
}));
|
|
|
|
const optionalShareFileAuth = require('./optionalShareFileAuth');
|
|
|
|
const run = async (req) => {
|
|
const next = jest.fn();
|
|
await optionalShareFileAuth(req, {}, next);
|
|
return next;
|
|
};
|
|
|
|
describe('optionalShareFileAuth', () => {
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
mockIsTwoFactorEnrollmentRequired.mockReturnValue(false);
|
|
mockIsTokenRetired.mockReturnValue(false);
|
|
process.env.JWT_REFRESH_SECRET = 'test-secret';
|
|
});
|
|
|
|
it('short-circuits when a bearer user is already set (no cookie work)', async () => {
|
|
const req = { user: { id: 'u1' }, headers: { cookie: 'refreshToken=x' } };
|
|
const next = await run(req);
|
|
expect(next).toHaveBeenCalledTimes(1);
|
|
expect(mockVerify).not.toHaveBeenCalled();
|
|
expect(mockGetUserById).not.toHaveBeenCalled();
|
|
expect(mockFindSession).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('removes an existing viewer when required enrollment is incomplete', async () => {
|
|
mockIsTwoFactorEnrollmentRequired.mockReturnValue(true);
|
|
const req = {
|
|
user: { id: 'u1', provider: 'local', twoFactorEnabled: false },
|
|
authStrategy: 'jwt',
|
|
headers: { cookie: 'refreshToken=x' },
|
|
};
|
|
|
|
const next = await run(req);
|
|
|
|
expect(next).toHaveBeenCalledTimes(1);
|
|
expect(req.user).toBeUndefined();
|
|
expect(req.authStrategy).toBeUndefined();
|
|
expect(mockClearCloudFrontCookies).toHaveBeenCalledWith(expect.any(Object), {
|
|
userId: 'u1',
|
|
tenantId: undefined,
|
|
storageRegion: undefined,
|
|
});
|
|
expect(mockGetUserById).not.toHaveBeenCalled();
|
|
expect(mockFindSession).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('resolves the viewer from a valid refreshToken cookie with a live session', async () => {
|
|
mockVerify.mockReturnValue({ id: 'viewer-1' });
|
|
mockFindSession.mockResolvedValue({ _id: 'session-1' });
|
|
mockGetUserById.mockResolvedValue({ _id: 'viewer-1', role: 'USER' });
|
|
const req = { headers: { cookie: 'refreshToken=good.jwt' } };
|
|
const next = await run(req);
|
|
expect(next).toHaveBeenCalledTimes(1);
|
|
expect(mockVerify).toHaveBeenCalledWith('good.jwt', 'test-secret');
|
|
expect(mockFindSession).toHaveBeenCalledWith({ userId: 'viewer-1', refreshToken: 'good.jwt' });
|
|
expect(mockRunAsSystem).toHaveBeenCalledTimes(2);
|
|
expect(req.user).toMatchObject({ id: 'viewer-1', role: 'USER' });
|
|
});
|
|
|
|
it('does not restore a cookie viewer when required enrollment is incomplete', async () => {
|
|
mockVerify.mockReturnValue({ id: 'viewer-required' });
|
|
mockFindSession.mockResolvedValue({ _id: 'session-required' });
|
|
mockGetUserById.mockResolvedValue({
|
|
_id: 'viewer-required',
|
|
role: 'USER',
|
|
provider: 'local',
|
|
twoFactorEnabled: false,
|
|
});
|
|
mockIsTwoFactorEnrollmentRequired.mockReturnValue(true);
|
|
const req = { headers: { cookie: 'refreshToken=good.jwt' } };
|
|
|
|
const next = await run(req);
|
|
|
|
expect(next).toHaveBeenCalledTimes(1);
|
|
expect(req.user).toBeUndefined();
|
|
expect(mockClearCloudFrontCookies).toHaveBeenCalledWith(expect.any(Object), {
|
|
userId: 'viewer-required',
|
|
tenantId: undefined,
|
|
storageRegion: undefined,
|
|
});
|
|
expect(mockGetUserById).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('does not restore a cookie viewer minted before two-factor enrollment', async () => {
|
|
mockVerify.mockReturnValue({ id: 'viewer-stale', iat: 1000 });
|
|
mockFindSession.mockResolvedValue({ _id: 'session-stale' });
|
|
mockGetUserById.mockResolvedValue({
|
|
_id: 'viewer-stale',
|
|
role: 'USER',
|
|
provider: 'local',
|
|
twoFactorEnabled: true,
|
|
twoFactorEnrolledAt: new Date(2000 * 1000),
|
|
});
|
|
mockIsTokenRetired.mockReturnValue(true);
|
|
const req = { headers: { cookie: 'refreshToken=stale.jwt' } };
|
|
|
|
const next = await run(req);
|
|
|
|
expect(next).toHaveBeenCalledTimes(1);
|
|
expect(req.user).toBeUndefined();
|
|
expect(mockClearCloudFrontCookies).toHaveBeenCalledWith(expect.any(Object), {
|
|
userId: 'viewer-stale',
|
|
tenantId: undefined,
|
|
storageRegion: undefined,
|
|
});
|
|
});
|
|
|
|
it('dates the cookie by its own mint stamps rather than discarding them', async () => {
|
|
const enrolledAt = new Date(2000 * 1000);
|
|
mockVerify.mockReturnValue({ id: 'viewer-dated', iat: 1234, issuedAtMs: 1234_500 });
|
|
mockFindSession.mockResolvedValue({ _id: 'session-dated' });
|
|
mockGetUserById.mockResolvedValue({
|
|
_id: 'viewer-dated',
|
|
role: 'USER',
|
|
twoFactorEnrolledAt: enrolledAt,
|
|
});
|
|
const req = { headers: { cookie: 'refreshToken=dated.jwt' } };
|
|
|
|
await run(req);
|
|
|
|
expect(mockIsTokenRetired).toHaveBeenCalledWith(
|
|
expect.objectContaining({ issuedAt: 1234, issuedAtMs: 1234_500 }),
|
|
expect.objectContaining({ twoFactorEnrolledAt: enrolledAt }),
|
|
);
|
|
});
|
|
|
|
it('dates an OpenID cookie viewer by its own mint stamps too', async () => {
|
|
process.env.OPENID_REUSE_TOKENS = 'true';
|
|
const enrolledAt = new Date(2000 * 1000);
|
|
mockVerify.mockReturnValue({ id: 'viewer-openid', iat: 4321, issuedAtMs: 4321_500 });
|
|
mockGetUserById.mockResolvedValue({
|
|
_id: 'viewer-openid',
|
|
role: 'USER',
|
|
twoFactorEnrolledAt: enrolledAt,
|
|
});
|
|
const req = {
|
|
headers: {
|
|
cookie: 'refreshToken=live.jwt; token_provider=openid; openid_user_id=signed.jwt',
|
|
},
|
|
session: { openidTokens: { refreshToken: 'live.jwt' } },
|
|
};
|
|
|
|
await run(req);
|
|
|
|
expect(mockIsTokenRetired).toHaveBeenCalledWith(
|
|
expect.objectContaining({ issuedAt: 4321, issuedAtMs: 4321_500 }),
|
|
expect.objectContaining({ twoFactorEnrolledAt: enrolledAt }),
|
|
);
|
|
delete process.env.OPENID_REUSE_TOKENS;
|
|
});
|
|
|
|
it('defaults the role to USER when the record has none', async () => {
|
|
mockVerify.mockReturnValue({ id: 'viewer-2' });
|
|
mockFindSession.mockResolvedValue({ _id: 'session-2' });
|
|
mockGetUserById.mockResolvedValue({ _id: 'viewer-2' });
|
|
const req = { headers: { cookie: 'refreshToken=good.jwt' } };
|
|
await run(req);
|
|
expect(req.user.role).toBe('USER');
|
|
});
|
|
|
|
it('leaves req.user unset when there is no cookie', async () => {
|
|
const req = { headers: {} };
|
|
const next = await run(req);
|
|
expect(next).toHaveBeenCalledTimes(1);
|
|
expect(req.user).toBeUndefined();
|
|
expect(mockGetUserById).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('leaves req.user unset when the refresh token has no live session', async () => {
|
|
mockVerify.mockReturnValue({ id: 'viewer-3' });
|
|
mockFindSession.mockResolvedValue(null);
|
|
const req = { headers: { cookie: 'refreshToken=revoked.jwt' } };
|
|
const next = await run(req);
|
|
expect(next).toHaveBeenCalledTimes(1);
|
|
expect(req.user).toBeUndefined();
|
|
expect(mockFindSession).toHaveBeenCalledWith({
|
|
userId: 'viewer-3',
|
|
refreshToken: 'revoked.jwt',
|
|
});
|
|
expect(mockRunAsSystem).toHaveBeenCalledTimes(1);
|
|
expect(mockGetUserById).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('leaves req.user unset when the token is invalid', async () => {
|
|
mockVerify.mockImplementation(() => {
|
|
throw new Error('bad token');
|
|
});
|
|
const req = { headers: { cookie: 'refreshToken=bad' } };
|
|
const next = await run(req);
|
|
expect(next).toHaveBeenCalledTimes(1);
|
|
expect(req.user).toBeUndefined();
|
|
expect(mockGetUserById).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('uses the signed openid_user_id cookie only for active OpenID-reuse sessions', async () => {
|
|
process.env.OPENID_REUSE_TOKENS = 'true';
|
|
mockVerify.mockReturnValue({ id: 'oidc-1' });
|
|
mockGetUserById.mockResolvedValue({ _id: 'oidc-1', role: 'USER' });
|
|
const req = {
|
|
headers: {
|
|
cookie: 'token_provider=openid; refreshToken=stored-refresh; openid_user_id=signed.jwt',
|
|
},
|
|
session: { openidTokens: { refreshToken: 'stored-refresh' } },
|
|
};
|
|
await run(req);
|
|
expect(mockVerify).toHaveBeenCalledWith('signed.jwt', 'test-secret');
|
|
expect(mockFindSession).not.toHaveBeenCalled();
|
|
expect(req.user).toMatchObject({ id: 'oidc-1' });
|
|
delete process.env.OPENID_REUSE_TOKENS;
|
|
});
|
|
|
|
it('leaves req.user unset for OpenID-reuse cookies without an active matching session', async () => {
|
|
process.env.OPENID_REUSE_TOKENS = 'true';
|
|
mockVerify.mockReturnValue({ id: 'oidc-2' });
|
|
const req = {
|
|
headers: {
|
|
cookie: 'token_provider=openid; refreshToken=stale-refresh; openid_user_id=signed.jwt',
|
|
},
|
|
session: { openidTokens: { refreshToken: 'current-refresh' } },
|
|
};
|
|
await run(req);
|
|
expect(req.user).toBeUndefined();
|
|
expect(mockGetUserById).not.toHaveBeenCalled();
|
|
delete process.env.OPENID_REUSE_TOKENS;
|
|
});
|
|
});
|