Skip to content

Commit 8ceace7

Browse files
🔄 feat: Body-Based OpenID Refresh for Cross-Origin Admin Panels (#46)
* 🔄 feat: Body-Based OpenID Refresh for Cross-Origin Admin Panels The cookie-based `/api/auth/refresh` controller can't be reached cross-origin because the refresh-token cookie doesn't ship on a fetch from a different host. Cross-origin admin panel sessions silently die at JWT expiry with no recovery. This routes `openid` sessions through the new `POST /api/admin/oauth/refresh` endpoint (danny-avila/LibreChat#13007) which accepts the refresh token in the request body and returns the same shape as `/api/admin/oauth/exchange`: `{ token, refreshToken, user, expiresAt }`. Non-openid sessions still use the legacy cookie-based path. The `expiresAt` field (ms epoch) is now threaded through `SessionData` and `OAuthExchangeResponse` so the admin panel can drive proactive refresh before the bearer expires. `refreshAdminToken` takes a new `userId` argument that's forwarded as `user_id` in the refresh request body for disambiguation when multiple user docs share the same OpenID `sub`. * feat: forward X-Tenant-Id and centralize bearer freshness in apiFetch Two follow-ups for the BFF refresh path now that the LibreChat backend scopes /api/admin/oauth/refresh by tenant. apiFetch was sending the session bearer as-is. If a query landed past JWT expiry it would fail before the 60s revalidation interval kicked in. apiFetch now reads expiresAt, refreshes proactively when the bearer is within 30s of expiry, persists any rotated refresh token, and retries the original request exactly once on a 401. The OpenID refresh request now forwards the deployment's X-Tenant-Id header so the backend's preAuthTenantMiddleware can scope the user lookup. Without this the backend would fall back to single-tenant behavior and the multi-tenant duplicate (sub, iss) protection added in danny-avila/LibreChat#13007 wouldn't activate. Refresh logic moves to a shared src/server/utils/refresh.ts so the verify path and apiFetch share one implementation. Concurrent callers are deduped on the refresh token so two React Query subscribers can't both consume a rotating token in the same BFF process. Adds 19 tests covering tenant header forwarded/omitted, dedupe, proactive refresh inside/outside skew, rotation persistence, and single-retry 401 behavior. * fix: break refresh URL import cycle * fix: include user, provider, and tenant in refresh dedupe key Keying the in-flight refresh map on refreshToken alone meant two concurrent calls that happen to share a token string but differ by userId, tokenProvider, or tenant would coalesce, and the second caller would receive the first caller's bearer and persist it into the wrong session. Build the dedupe key from tokenProvider, userId, the request's X-Tenant-Id header, and the refresh token (joined by NUL). Adds three regression tests covering each discriminator. --------- Co-authored-by: Danny Avila <danny@librechat.ai>
1 parent 7b7de06 commit 8ceace7

7 files changed

Lines changed: 647 additions & 66 deletions

File tree

src/server/auth.ts

Lines changed: 6 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@ import { SystemRoles } from 'librechat-data-provider';
66
import { createServerFn } from '@tanstack/react-start';
77
import { getRequestHeader } from '@tanstack/react-start/server';
88
import type * as t from '@/types';
9+
import { getApiBaseUrl, getServerApiUrl } from './utils/url';
10+
import { refreshAdminTokenDeduped } from './utils/refresh';
911
import { useAppSession, SESSION_CONFIG } from './session';
10-
import { getApiBaseUrl, getServerApiUrl } from './utils/api';
1112

1213
/** Extract a named cookie value from `set-cookie` response headers. */
1314
function extractCookieValue(response: Response, name: string): string | undefined {
@@ -146,50 +147,12 @@ const clearSession = async (session: Awaited<ReturnType<typeof useAppSession>>)
146147
user: undefined,
147148
refreshToken: undefined,
148149
tokenProvider: undefined,
150+
expiresAt: undefined,
149151
lastVerified: undefined,
150152
lastActivity: undefined,
151153
});
152154
};
153155

154-
/**
155-
* Attempt to refresh the JWT using the stored refresh token.
156-
* Returns the new token and refreshToken on success, or undefined on failure.
157-
*
158-
* Note: concurrent callers sharing a rotating refresh token may race; the
159-
* current call pattern (single React Query with 60s interval) is sequential.
160-
*/
161-
const refreshResponseSchema = z.object({ token: z.string() });
162-
163-
async function refreshAdminToken(
164-
refreshToken: string,
165-
tokenProvider: t.SessionData['tokenProvider'],
166-
): Promise<{ token: string; refreshToken?: string } | undefined> {
167-
try {
168-
const cookieParts = [`refreshToken=${refreshToken}`];
169-
if (tokenProvider === 'openid') {
170-
cookieParts.push('token_provider=openid');
171-
}
172-
173-
const response = await fetch(`${getServerApiUrl()}/api/auth/refresh`, {
174-
method: 'POST',
175-
headers: { Cookie: cookieParts.join('; ') },
176-
});
177-
178-
if (!response.ok) return undefined;
179-
180-
const parsed = refreshResponseSchema.safeParse(await response.json());
181-
if (!parsed.success) return undefined;
182-
183-
return {
184-
token: parsed.data.token,
185-
refreshToken: extractCookieValue(response, 'refreshToken'),
186-
};
187-
} catch (error) {
188-
console.warn('[refreshAdminToken] Token refresh request failed:', error);
189-
return undefined;
190-
}
191-
}
192-
193156
export const verifyAdminTokenFn = createServerFn({ method: 'GET' }).handler(async () => {
194157
try {
195158
const session = await useAppSession();
@@ -227,11 +190,12 @@ export const verifyAdminTokenFn = createServerFn({ method: 'GET' }).handler(asyn
227190
}
228191
if (response.status === 401) {
229192
if (refreshToken) {
230-
const refreshed = await refreshAdminToken(refreshToken, tokenProvider);
193+
const refreshed = await refreshAdminTokenDeduped(refreshToken, tokenProvider, user.id);
231194
if (refreshed) {
232195
const refreshedSession = {
233196
token: refreshed.token,
234197
refreshToken: refreshed.refreshToken ?? refreshToken,
198+
expiresAt: refreshed.expiresAt,
235199
lastVerified: now,
236200
lastActivity: now,
237201
};
@@ -422,6 +386,7 @@ export const oauthExchangeFn = createServerFn({ method: 'POST' })
422386
token: exchangeData.token,
423387
refreshToken: exchangeData.refreshToken ?? extractCookieValue(response, 'refreshToken'),
424388
tokenProvider: 'openid',
389+
expiresAt: exchangeData.expiresAt,
425390
lastVerified: now,
426391
lastActivity: now,
427392
codeVerifier: undefined,

src/server/utils/api.test.ts

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { describe, it, expect, beforeEach, vi } from 'vitest';
2+
3+
const ensureFreshBearer = vi.fn();
4+
const refreshOn401 = vi.fn();
5+
6+
vi.mock('./refresh', () => ({
7+
ensureFreshBearer: (...args: unknown[]) => ensureFreshBearer(...args),
8+
refreshOn401: (...args: unknown[]) => refreshOn401(...args),
9+
}));
10+
11+
import { apiFetch } from './api';
12+
13+
const fetchMock = vi.fn();
14+
15+
beforeEach(() => {
16+
fetchMock.mockReset();
17+
ensureFreshBearer.mockReset();
18+
refreshOn401.mockReset();
19+
vi.stubGlobal('fetch', fetchMock);
20+
});
21+
22+
function jsonResponse(status: number, body: unknown = {}): Response {
23+
return new Response(JSON.stringify(body), {
24+
status,
25+
headers: { 'Content-Type': 'application/json' },
26+
});
27+
}
28+
29+
describe('apiFetch', () => {
30+
it('throws when no bearer is available', async () => {
31+
ensureFreshBearer.mockResolvedValueOnce(undefined);
32+
await expect(apiFetch('/api/admin/grants')).rejects.toThrow(/No admin session token/);
33+
expect(fetchMock).not.toHaveBeenCalled();
34+
});
35+
36+
it('sends Authorization with the bearer returned by ensureFreshBearer', async () => {
37+
ensureFreshBearer.mockResolvedValueOnce('jwt-fresh');
38+
fetchMock.mockResolvedValueOnce(jsonResponse(200));
39+
40+
await apiFetch('/api/admin/grants');
41+
42+
const [, init] = fetchMock.mock.calls[0];
43+
const headers = (init as RequestInit).headers as Record<string, string>;
44+
expect(headers.Authorization).toBe('Bearer jwt-fresh');
45+
expect(refreshOn401).not.toHaveBeenCalled();
46+
});
47+
48+
it('passes through the proactive-refresh skew window of 30s', async () => {
49+
ensureFreshBearer.mockResolvedValueOnce('jwt-fresh');
50+
fetchMock.mockResolvedValueOnce(jsonResponse(200));
51+
await apiFetch('/api/admin/grants');
52+
expect(ensureFreshBearer).toHaveBeenCalledWith(30_000);
53+
});
54+
55+
it('retries exactly once on 401, using the refreshed bearer', async () => {
56+
ensureFreshBearer.mockResolvedValueOnce('jwt-stale');
57+
refreshOn401.mockResolvedValueOnce('jwt-fresh');
58+
fetchMock
59+
.mockResolvedValueOnce(jsonResponse(401, { error: 'expired' }))
60+
.mockResolvedValueOnce(jsonResponse(200, { ok: true }));
61+
62+
const response = await apiFetch('/api/admin/grants');
63+
64+
expect(response.status).toBe(200);
65+
expect(fetchMock).toHaveBeenCalledTimes(2);
66+
expect(refreshOn401).toHaveBeenCalledTimes(1);
67+
68+
const [, init1] = fetchMock.mock.calls[0];
69+
const [, init2] = fetchMock.mock.calls[1];
70+
expect((init1 as RequestInit).headers).toMatchObject({ Authorization: 'Bearer jwt-stale' });
71+
expect((init2 as RequestInit).headers).toMatchObject({ Authorization: 'Bearer jwt-fresh' });
72+
});
73+
74+
it('does not retry when the second response is also 401', async () => {
75+
ensureFreshBearer.mockResolvedValueOnce('jwt-stale');
76+
refreshOn401.mockResolvedValueOnce('jwt-fresh');
77+
fetchMock
78+
.mockResolvedValueOnce(jsonResponse(401))
79+
.mockResolvedValueOnce(jsonResponse(401, { error: 'still bad' }));
80+
81+
const response = await apiFetch('/api/admin/grants');
82+
83+
expect(response.status).toBe(401);
84+
expect(fetchMock).toHaveBeenCalledTimes(2);
85+
expect(refreshOn401).toHaveBeenCalledTimes(1);
86+
});
87+
88+
it('returns the original 401 when refreshOn401 fails', async () => {
89+
ensureFreshBearer.mockResolvedValueOnce('jwt-stale');
90+
refreshOn401.mockResolvedValueOnce(undefined);
91+
const expired = jsonResponse(401, { error: 'expired' });
92+
fetchMock.mockResolvedValueOnce(expired);
93+
94+
const response = await apiFetch('/api/admin/grants');
95+
96+
expect(response.status).toBe(401);
97+
expect(fetchMock).toHaveBeenCalledTimes(1);
98+
});
99+
100+
it('does not call refreshOn401 on non-401 errors', async () => {
101+
ensureFreshBearer.mockResolvedValueOnce('jwt-fresh');
102+
fetchMock.mockResolvedValueOnce(jsonResponse(500, { error: 'server' }));
103+
104+
const response = await apiFetch('/api/admin/grants');
105+
106+
expect(response.status).toBe(500);
107+
expect(refreshOn401).not.toHaveBeenCalled();
108+
});
109+
110+
it('lets caller-supplied headers be overridden by the Authorization header', async () => {
111+
ensureFreshBearer.mockResolvedValueOnce('jwt-fresh');
112+
fetchMock.mockResolvedValueOnce(jsonResponse(200));
113+
114+
await apiFetch('/api/admin/grants', {
115+
method: 'POST',
116+
headers: { Authorization: 'Bearer attacker', 'X-Custom': 'keep-me' },
117+
});
118+
119+
const [, init] = fetchMock.mock.calls[0];
120+
const headers = (init as RequestInit).headers as Record<string, string>;
121+
expect(headers.Authorization).toBe('Bearer jwt-fresh');
122+
expect(headers['X-Custom']).toBe('keep-me');
123+
});
124+
});

src/server/utils/api.ts

Lines changed: 27 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,47 @@
1-
import { useAppSession } from '../session';
1+
import { ensureFreshBearer, refreshOn401 } from './refresh';
2+
import { getServerApiUrl } from './url';
23

3-
export function getApiBaseUrl(): string {
4-
if (typeof process !== 'undefined' && process.env?.VITE_API_BASE_URL) {
5-
return process.env.VITE_API_BASE_URL;
6-
}
7-
if (typeof import.meta !== 'undefined' && import.meta.env?.VITE_API_BASE_URL) {
8-
return import.meta.env.VITE_API_BASE_URL;
9-
}
10-
return 'http://localhost:3080';
11-
}
12-
13-
/** Server-to-server API URL. Falls back to getApiBaseUrl() if API_SERVER_URL is not set. */
14-
export function getServerApiUrl(): string {
15-
if (typeof process !== 'undefined' && process.env?.API_SERVER_URL) {
16-
return process.env.API_SERVER_URL;
17-
}
18-
return getApiBaseUrl();
19-
}
4+
/** Skew window: refresh proactively when the bearer is within this of expiry. */
5+
const PROACTIVE_REFRESH_SKEW_MS = 30_000;
206

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

3425
const url = `${getServerApiUrl()}${path}`;
35-
return fetch(url, {
26+
const buildInit = (token: string): RequestInit => ({
3627
...init,
3728
headers: {
3829
'Content-Type': 'application/json',
39-
Authorization: `Bearer ${token}`,
4030
...init?.headers,
31+
Authorization: `Bearer ${token}`,
4132
},
4233
});
34+
35+
const response = await fetch(url, buildInit(initialToken));
36+
if (response.status !== 401) {
37+
return response;
38+
}
39+
40+
const refreshedToken = await refreshOn401();
41+
if (!refreshedToken) {
42+
return response;
43+
}
44+
return fetch(url, buildInit(refreshedToken));
4345
}
4446

4547
/**

0 commit comments

Comments
 (0)