Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 144 additions & 1 deletion src/server/auth.oauth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,13 @@ vi.mock('./utils/refresh', () => ({
refreshAdminTokenDeduped: vi.fn(),
}));

import { checkOpenIdFn, oauthExchangeFn } from './auth';
import {
adminLoginFn,
adminVerify2FAFn,
checkOpenIdFn,
oauthExchangeFn,
verifyAdminTokenFn,
} from './auth';

function jsonResponse(status: number, body: unknown): Response {
return new Response(JSON.stringify(body), {
Expand All @@ -53,6 +59,143 @@ function jsonResponse(status: number, body: unknown): Response {
});
}

describe('adminLoginFn', () => {
beforeEach(() => {
fetchMock.mockReset();
updateSession.mockReset();
sessionState.data = {};
vi.stubGlobal('fetch', fetchMock);
});

it('accepts a backend-approved delegated admin without requiring the ADMIN role', async () => {
const user = { id: 'user-1', role: 'department-admin', email: 'delegate@example.com' };
fetchMock.mockResolvedValueOnce(jsonResponse(200, { token: 'jwt-token', user }));

const result = await adminLoginFn({
data: { email: 'delegate@example.com', password: 'password' },
});

expect(result).toEqual({ error: false, user });
expect(fetchMock).toHaveBeenCalledWith('http://librechat.test/api/admin/login/local', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'delegate@example.com', password: 'password' }),
});
expect(updateSession).toHaveBeenCalledWith(
expect.objectContaining({
user,
token: 'jwt-token',
tokenProvider: 'librechat',
}),
);
});
});

describe('adminVerify2FAFn', () => {
beforeEach(() => {
fetchMock.mockReset();
updateSession.mockReset();
sessionState.data = {};
vi.stubGlobal('fetch', fetchMock);
});

it('accepts a backend-approved delegated admin after 2FA verification', async () => {
const user = { id: 'user-2', role: 'department-admin', email: 'delegate2@example.com' };
const verifiedUser = { ...user, name: 'Delegated Admin' };
fetchMock
.mockResolvedValueOnce(jsonResponse(200, { token: 'jwt-token-2', user }))
.mockResolvedValueOnce(jsonResponse(200, { user: verifiedUser }));

const result = await adminVerify2FAFn({
data: { tempToken: 'temp-token', totpCode: '123456' },
});

expect(result).toEqual({ error: false, user: verifiedUser });
expect(fetchMock).toHaveBeenCalledWith('http://librechat.test/api/auth/2fa/verify-temp', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tempToken: 'temp-token', token: '123456' }),
});
expect(fetchMock).toHaveBeenCalledWith('http://librechat.test/api/admin/verify', {
headers: { Authorization: 'Bearer jwt-token-2' },
});
expect(updateSession).toHaveBeenCalledWith(
expect.objectContaining({
user: verifiedUser,
token: 'jwt-token-2',
tokenProvider: 'librechat',
}),
);
});

it('rejects a 2FA token that does not pass admin capability revalidation', async () => {
const user = { id: 'user-2', role: 'regular-user', email: 'user@example.com' };
fetchMock
.mockResolvedValueOnce(jsonResponse(200, { token: 'user-jwt-token', user }))
.mockResolvedValueOnce(jsonResponse(403, {}));

const result = await adminVerify2FAFn({
data: { tempToken: 'temp-token', totpCode: '123456' },
});

expect(result).toEqual({ error: true, message: 'You do not have admin privileges' });
expect(fetchMock).toHaveBeenCalledWith('http://librechat.test/api/admin/verify', {
headers: { Authorization: 'Bearer user-jwt-token' },
});
expect(updateSession).not.toHaveBeenCalled();
});
});

describe('verifyAdminTokenFn', () => {
beforeEach(() => {
fetchMock.mockReset();
updateSession.mockReset();
sessionState.data = {};
vi.stubGlobal('fetch', fetchMock);
});

it('keeps a fresh delegated admin session without requiring the ADMIN role', async () => {
const user = { id: 'user-3', role: 'department-admin', email: 'delegate3@example.com' };
sessionState.data = {
user,
token: 'jwt-token-3',
lastVerified: Date.now(),
lastActivity: Date.now(),
};

const result = await verifyAdminTokenFn();

expect(result).toEqual({ valid: true, user });
expect(fetchMock).not.toHaveBeenCalled();
expect(updateSession).toHaveBeenCalledWith({ lastActivity: expect.any(Number) });
});

it('clears a delegated admin session when backend capability revalidation is denied', async () => {
const user = { id: 'user-4', role: 'department-admin', email: 'delegate4@example.com' };
sessionState.data = {
user,
token: 'jwt-token-4',
lastVerified: 0,
lastActivity: Date.now(),
};
fetchMock.mockResolvedValueOnce(jsonResponse(403, {}));

const result = await verifyAdminTokenFn();

expect(result).toEqual({ valid: false, error: 'Admin privileges have been revoked' });
expect(fetchMock).toHaveBeenCalledWith('http://librechat.test/api/admin/verify', {
headers: { Authorization: 'Bearer jwt-token-4' },
});
expect(updateSession).toHaveBeenCalledWith(
expect.objectContaining({
token: undefined,
user: undefined,
refreshToken: undefined,
}),
);
});
});

describe('oauthExchangeFn', () => {
beforeEach(() => {
fetchMock.mockReset();
Expand Down
29 changes: 15 additions & 14 deletions src/server/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { z } from 'zod';
import crypto from 'crypto';
import { redirect } from '@tanstack/react-router';
import { queryOptions } from '@tanstack/react-query';
import { SystemRoles } from 'librechat-data-provider';
import { createServerFn } from '@tanstack/react-start';
import { getRequestHeader } from '@tanstack/react-start/server';
import type * as t from '@/types';
Expand Down Expand Up @@ -84,10 +83,6 @@ export const adminLoginFn = createServerFn({ method: 'POST' })
};
}

if (loginData.user.role !== SystemRoles.ADMIN) {
return { error: true, message: 'You do not have admin privileges' };
}

const now = Date.now();
const session = await useAppSession();
await session.update({
Expand Down Expand Up @@ -139,23 +134,34 @@ export const adminVerify2FAFn = createServerFn({ method: 'POST' })
}

const verifyData = responseData as t.TwoFAVerifyResponse;
const adminVerifyResponse = await fetch(`${getServerApiUrl()}/api/admin/verify`, {
headers: { Authorization: `Bearer ${verifyData.token}` },
});

if (verifyData.user.role !== SystemRoles.ADMIN) {
return { error: true, message: 'You do not have admin privileges' };
if (!adminVerifyResponse.ok) {
if (adminVerifyResponse.status === 403) {
return { error: true, message: 'You do not have admin privileges' };
}
if (adminVerifyResponse.status === 401) {
return { error: true, message: 'Session is no longer valid' };
}
return { error: true, message: '2FA verification failed' };
}

const adminVerifyData = (await adminVerifyResponse.json()) as t.AdminVerifyResponse;

const now = Date.now();
const session = await useAppSession();
await session.update({
Comment thread
danny-avila marked this conversation as resolved.
user: verifyData.user,
user: adminVerifyData.user,
token: verifyData.token,
refreshToken: extractCookieValue(response, 'refreshToken'),
tokenProvider: 'librechat',
lastVerified: now,
lastActivity: now,
});

return { error: false, user: verifyData.user };
return { error: false, user: adminVerifyData.user };
} catch (error) {
console.error('2FA verification error:', error);
return { error: true, message: 'Verification failed. Please try again.' };
Expand Down Expand Up @@ -183,11 +189,6 @@ export const verifyAdminTokenFn = createServerFn({ method: 'GET' }).handler(asyn
return { valid: false, error: 'No session found' };
}

if (user.role !== SystemRoles.ADMIN) {
await clearSession(session);
return { valid: false, error: 'Not an admin user' };
}

const now = Date.now();

if (lastActivity && now - lastActivity > SESSION_CONFIG.idleTimeout) {
Expand Down
4 changes: 4 additions & 0 deletions src/types/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ export interface TwoFAVerifyResponse {
user: SerializableUser;
}

export interface AdminVerifyResponse {
user: SerializableUser;
}

export interface OAuthExchangeResponse {
token: string;
refreshToken?: string;
Expand Down