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
47 changes: 6 additions & 41 deletions src/server/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ 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';
import { getApiBaseUrl, getServerApiUrl } from './utils/url';
import { refreshAdminTokenDeduped } from './utils/refresh';
import { useAppSession, SESSION_CONFIG } from './session';
import { getApiBaseUrl, getServerApiUrl } from './utils/api';

/** Extract a named cookie value from `set-cookie` response headers. */
function extractCookieValue(response: Response, name: string): string | undefined {
Expand Down Expand Up @@ -146,50 +147,12 @@ const clearSession = async (session: Awaited<ReturnType<typeof useAppSession>>)
user: undefined,
refreshToken: undefined,
tokenProvider: undefined,
expiresAt: undefined,
lastVerified: undefined,
lastActivity: undefined,
});
};

/**
* Attempt to refresh the JWT using the stored refresh token.
* Returns the new token and refreshToken on success, or undefined on failure.
*
* Note: concurrent callers sharing a rotating refresh token may race; the
* current call pattern (single React Query with 60s interval) is sequential.
*/
const refreshResponseSchema = z.object({ token: z.string() });

async function refreshAdminToken(
refreshToken: string,
tokenProvider: t.SessionData['tokenProvider'],
): Promise<{ token: string; refreshToken?: string } | undefined> {
try {
const cookieParts = [`refreshToken=${refreshToken}`];
if (tokenProvider === 'openid') {
cookieParts.push('token_provider=openid');
}

const response = await fetch(`${getServerApiUrl()}/api/auth/refresh`, {
method: 'POST',
headers: { Cookie: cookieParts.join('; ') },
});

if (!response.ok) return undefined;

const parsed = refreshResponseSchema.safeParse(await response.json());
if (!parsed.success) return undefined;

return {
token: parsed.data.token,
refreshToken: extractCookieValue(response, 'refreshToken'),
};
} catch (error) {
console.warn('[refreshAdminToken] Token refresh request failed:', error);
return undefined;
}
}

export const verifyAdminTokenFn = createServerFn({ method: 'GET' }).handler(async () => {
try {
const session = await useAppSession();
Expand Down Expand Up @@ -227,11 +190,12 @@ export const verifyAdminTokenFn = createServerFn({ method: 'GET' }).handler(asyn
}
if (response.status === 401) {
if (refreshToken) {
const refreshed = await refreshAdminToken(refreshToken, tokenProvider);
const refreshed = await refreshAdminTokenDeduped(refreshToken, tokenProvider, user.id);
if (refreshed) {
const refreshedSession = {
token: refreshed.token,
refreshToken: refreshed.refreshToken ?? refreshToken,
expiresAt: refreshed.expiresAt,
lastVerified: now,
lastActivity: now,
};
Expand Down Expand Up @@ -422,6 +386,7 @@ export const oauthExchangeFn = createServerFn({ method: 'POST' })
token: exchangeData.token,
refreshToken: exchangeData.refreshToken ?? extractCookieValue(response, 'refreshToken'),
tokenProvider: 'openid',
expiresAt: exchangeData.expiresAt,
lastVerified: now,
lastActivity: now,
codeVerifier: undefined,
Expand Down
124 changes: 124 additions & 0 deletions src/server/utils/api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';

const ensureFreshBearer = vi.fn();
const refreshOn401 = vi.fn();

vi.mock('./refresh', () => ({
ensureFreshBearer: (...args: unknown[]) => ensureFreshBearer(...args),
refreshOn401: (...args: unknown[]) => refreshOn401(...args),
}));

import { apiFetch } from './api';

const fetchMock = vi.fn();

beforeEach(() => {
fetchMock.mockReset();
ensureFreshBearer.mockReset();
refreshOn401.mockReset();
vi.stubGlobal('fetch', fetchMock);
});

function jsonResponse(status: number, body: unknown = {}): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
}

describe('apiFetch', () => {
it('throws when no bearer is available', async () => {
ensureFreshBearer.mockResolvedValueOnce(undefined);
await expect(apiFetch('/api/admin/grants')).rejects.toThrow(/No admin session token/);
expect(fetchMock).not.toHaveBeenCalled();
});

it('sends Authorization with the bearer returned by ensureFreshBearer', async () => {
ensureFreshBearer.mockResolvedValueOnce('jwt-fresh');
fetchMock.mockResolvedValueOnce(jsonResponse(200));

await apiFetch('/api/admin/grants');

const [, init] = fetchMock.mock.calls[0];
const headers = (init as RequestInit).headers as Record<string, string>;
expect(headers.Authorization).toBe('Bearer jwt-fresh');
expect(refreshOn401).not.toHaveBeenCalled();
});

it('passes through the proactive-refresh skew window of 30s', async () => {
ensureFreshBearer.mockResolvedValueOnce('jwt-fresh');
fetchMock.mockResolvedValueOnce(jsonResponse(200));
await apiFetch('/api/admin/grants');
expect(ensureFreshBearer).toHaveBeenCalledWith(30_000);
});

it('retries exactly once on 401, using the refreshed bearer', async () => {
ensureFreshBearer.mockResolvedValueOnce('jwt-stale');
refreshOn401.mockResolvedValueOnce('jwt-fresh');
fetchMock
.mockResolvedValueOnce(jsonResponse(401, { error: 'expired' }))
.mockResolvedValueOnce(jsonResponse(200, { ok: true }));

const response = await apiFetch('/api/admin/grants');

expect(response.status).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(refreshOn401).toHaveBeenCalledTimes(1);

const [, init1] = fetchMock.mock.calls[0];
const [, init2] = fetchMock.mock.calls[1];
expect((init1 as RequestInit).headers).toMatchObject({ Authorization: 'Bearer jwt-stale' });
expect((init2 as RequestInit).headers).toMatchObject({ Authorization: 'Bearer jwt-fresh' });
});

it('does not retry when the second response is also 401', async () => {
ensureFreshBearer.mockResolvedValueOnce('jwt-stale');
refreshOn401.mockResolvedValueOnce('jwt-fresh');
fetchMock
.mockResolvedValueOnce(jsonResponse(401))
.mockResolvedValueOnce(jsonResponse(401, { error: 'still bad' }));

const response = await apiFetch('/api/admin/grants');

expect(response.status).toBe(401);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(refreshOn401).toHaveBeenCalledTimes(1);
});

it('returns the original 401 when refreshOn401 fails', async () => {
ensureFreshBearer.mockResolvedValueOnce('jwt-stale');
refreshOn401.mockResolvedValueOnce(undefined);
const expired = jsonResponse(401, { error: 'expired' });
fetchMock.mockResolvedValueOnce(expired);

const response = await apiFetch('/api/admin/grants');

expect(response.status).toBe(401);
expect(fetchMock).toHaveBeenCalledTimes(1);
});

it('does not call refreshOn401 on non-401 errors', async () => {
ensureFreshBearer.mockResolvedValueOnce('jwt-fresh');
fetchMock.mockResolvedValueOnce(jsonResponse(500, { error: 'server' }));

const response = await apiFetch('/api/admin/grants');

expect(response.status).toBe(500);
expect(refreshOn401).not.toHaveBeenCalled();
});

it('lets caller-supplied headers be overridden by the Authorization header', async () => {
ensureFreshBearer.mockResolvedValueOnce('jwt-fresh');
fetchMock.mockResolvedValueOnce(jsonResponse(200));

await apiFetch('/api/admin/grants', {
method: 'POST',
headers: { Authorization: 'Bearer attacker', 'X-Custom': 'keep-me' },
});

const [, init] = fetchMock.mock.calls[0];
const headers = (init as RequestInit).headers as Record<string, string>;
expect(headers.Authorization).toBe('Bearer jwt-fresh');
expect(headers['X-Custom']).toBe('keep-me');
});
});
52 changes: 27 additions & 25 deletions src/server/utils/api.ts
Original file line number Diff line number Diff line change
@@ -1,45 +1,47 @@
import { useAppSession } from '../session';
import { ensureFreshBearer, refreshOn401 } from './refresh';
import { getServerApiUrl } from './url';

export function getApiBaseUrl(): string {
if (typeof process !== 'undefined' && process.env?.VITE_API_BASE_URL) {
return process.env.VITE_API_BASE_URL;
}
if (typeof import.meta !== 'undefined' && import.meta.env?.VITE_API_BASE_URL) {
return import.meta.env.VITE_API_BASE_URL;
}
return 'http://localhost:3080';
}

/** Server-to-server API URL. Falls back to getApiBaseUrl() if API_SERVER_URL is not set. */
export function getServerApiUrl(): string {
if (typeof process !== 'undefined' && process.env?.API_SERVER_URL) {
return process.env.API_SERVER_URL;
}
return getApiBaseUrl();
}
/** Skew window: refresh proactively when the bearer is within this of expiry. */
const PROACTIVE_REFRESH_SKEW_MS = 30_000;

/**
* Make an authenticated request to the LibreChat API.
* Reads the JWT token from the admin session and sets the Authorization header.
*
* @throws {Error} If no session token is available
* Centralises bearer freshness: refreshes proactively when `expiresAt` is
* within {@link PROACTIVE_REFRESH_SKEW_MS} of now, persists any rotated
* refresh token to the session, and retries the original request once on a
* 401 (so a token that expired between the freshness check and the request
* landing still recovers without bubbling the failure up to the caller).
*
* @throws {Error} If no session bearer is available even after a refresh
* attempt.
*/
export async function apiFetch(path: string, init?: RequestInit): Promise<Response> {
const session = await useAppSession();
const token = session.data.token;
if (!token) {
const initialToken = await ensureFreshBearer(PROACTIVE_REFRESH_SKEW_MS);
if (!initialToken) {
throw new Error('No admin session token available');
}

const url = `${getServerApiUrl()}${path}`;
return fetch(url, {
const buildInit = (token: string): RequestInit => ({
...init,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
...init?.headers,
Authorization: `Bearer ${token}`,
},
});

const response = await fetch(url, buildInit(initialToken));
if (response.status !== 401) {
return response;
}

const refreshedToken = await refreshOn401();
if (!refreshedToken) {
return response;
}
return fetch(url, buildInit(refreshedToken));
}

/**
Expand Down
Loading