diff --git a/src/server/auth.ts b/src/server/auth.ts index 9f518f7..c05ae44 100644 --- a/src/server/auth.ts +++ b/src/server/auth.ts @@ -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 { @@ -146,50 +147,12 @@ const clearSession = async (session: Awaited>) 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(); @@ -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, }; @@ -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, diff --git a/src/server/utils/api.test.ts b/src/server/utils/api.test.ts new file mode 100644 index 0000000..d858aec --- /dev/null +++ b/src/server/utils/api.test.ts @@ -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; + 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; + expect(headers.Authorization).toBe('Bearer jwt-fresh'); + expect(headers['X-Custom']).toBe('keep-me'); + }); +}); diff --git a/src/server/utils/api.ts b/src/server/utils/api.ts index f2b921c..0227426 100644 --- a/src/server/utils/api.ts +++ b/src/server/utils/api.ts @@ -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 { - 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)); } /** diff --git a/src/server/utils/refresh.test.ts b/src/server/utils/refresh.test.ts new file mode 100644 index 0000000..457dcc6 --- /dev/null +++ b/src/server/utils/refresh.test.ts @@ -0,0 +1,276 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +const sessionState: { data: Record } = { data: {} }; +const updateSpy = vi.fn(async (next: Record) => { + sessionState.data = { ...sessionState.data, ...next }; +}); + +vi.mock('../session', () => ({ + useAppSession: vi.fn(async () => ({ + get data() { + return sessionState.data; + }, + update: updateSpy, + })), +})); + +const tenantHeader: { value: string | undefined } = { value: undefined }; + +vi.mock('@tanstack/react-start/server', () => ({ + getRequestHeader: vi.fn((name: string) => { + if (name.toLowerCase() === 'x-tenant-id') return tenantHeader.value; + return undefined; + }), +})); + +vi.mock('./url', () => ({ + getServerApiUrl: () => 'http://lc.test', + getApiBaseUrl: () => 'http://lc.test', +})); + +import { + refreshAdminToken, + ensureFreshBearer, + refreshOn401, + refreshAdminTokenDeduped, +} from './refresh'; + +const fetchMock = vi.fn(); +beforeEach(() => { + fetchMock.mockReset(); + updateSpy.mockClear(); + sessionState.data = {}; + tenantHeader.value = undefined; + vi.stubGlobal('fetch', fetchMock); +}); + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +describe('refreshAdminToken — tenant header forwarding', () => { + it('forwards X-Tenant-Id when the BFF request carried one', async () => { + tenantHeader.value = 'tenant-a'; + fetchMock.mockResolvedValueOnce( + jsonResponse(200, { token: 'new-jwt', refreshToken: 'rt2', expiresAt: 999 }), + ); + + await refreshAdminToken('rt1', 'openid', 'user-1'); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('http://lc.test/api/admin/oauth/refresh'); + expect((init as RequestInit).headers).toMatchObject({ 'X-Tenant-Id': 'tenant-a' }); + }); + + it('omits X-Tenant-Id when the BFF request had no tenant header', async () => { + tenantHeader.value = undefined; + fetchMock.mockResolvedValueOnce( + jsonResponse(200, { token: 'new-jwt', refreshToken: 'rt2', expiresAt: 999 }), + ); + + await refreshAdminToken('rt1', 'openid', 'user-1'); + + const [, init] = fetchMock.mock.calls[0]; + const headers = (init as RequestInit).headers as Record; + expect(headers['X-Tenant-Id']).toBeUndefined(); + }); + + it('omits X-Tenant-Id when the header is whitespace only', async () => { + tenantHeader.value = ' '; + fetchMock.mockResolvedValueOnce( + jsonResponse(200, { token: 'new-jwt' }), + ); + + await refreshAdminToken('rt1', 'openid', 'user-1'); + + const [, init] = fetchMock.mock.calls[0]; + const headers = (init as RequestInit).headers as Record; + expect(headers['X-Tenant-Id']).toBeUndefined(); + }); +}); + +describe('refreshAdminTokenDeduped', () => { + it('coalesces concurrent calls sharing the same refresh token into a single request', async () => { + let resolveFn: ((value: Response) => void) | undefined; + fetchMock.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFn = resolve; + }), + ); + + const a = refreshAdminTokenDeduped('rt-shared', 'openid', 'user-1'); + const b = refreshAdminTokenDeduped('rt-shared', 'openid', 'user-1'); + + expect(fetchMock).toHaveBeenCalledTimes(1); + + resolveFn?.(jsonResponse(200, { token: 'new-jwt', refreshToken: 'rt2' })); + + const [resA, resB] = await Promise.all([a, b]); + expect(resA).toEqual(resB); + expect(resA?.token).toBe('new-jwt'); + }); + + it('does not coalesce when userId differs even with the same refresh token', async () => { + fetchMock + .mockResolvedValueOnce(jsonResponse(200, { token: 'jwt-user-1' })) + .mockResolvedValueOnce(jsonResponse(200, { token: 'jwt-user-2' })); + + const a = refreshAdminTokenDeduped('rt-shared', 'openid', 'user-1'); + const b = refreshAdminTokenDeduped('rt-shared', 'openid', 'user-2'); + + const [resA, resB] = await Promise.all([a, b]); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(resA?.token).toBe('jwt-user-1'); + expect(resB?.token).toBe('jwt-user-2'); + }); + + it('does not coalesce when tokenProvider differs', async () => { + fetchMock + .mockResolvedValueOnce(jsonResponse(200, { token: 'jwt-openid' })) + .mockResolvedValueOnce(jsonResponse(200, { token: 'jwt-librechat' })); + + const a = refreshAdminTokenDeduped('rt-shared', 'openid', 'user-1'); + const b = refreshAdminTokenDeduped('rt-shared', 'librechat', 'user-1'); + + const [resA, resB] = await Promise.all([a, b]); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(resA?.token).toBe('jwt-openid'); + expect(resB?.token).toBe('jwt-librechat'); + }); + + it('does not coalesce when tenant header differs between concurrent calls', async () => { + fetchMock + .mockImplementationOnce(async () => jsonResponse(200, { token: 'jwt-tenant-a' })) + .mockImplementationOnce(async () => jsonResponse(200, { token: 'jwt-tenant-b' })); + + tenantHeader.value = 'tenant-a'; + const a = refreshAdminTokenDeduped('rt-shared', 'openid', 'user-1'); + tenantHeader.value = 'tenant-b'; + const b = refreshAdminTokenDeduped('rt-shared', 'openid', 'user-1'); + + const [resA, resB] = await Promise.all([a, b]); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(resA?.token).toBe('jwt-tenant-a'); + expect(resB?.token).toBe('jwt-tenant-b'); + }); +}); + +describe('ensureFreshBearer', () => { + it('returns the existing token when expiresAt is far in the future', async () => { + sessionState.data = { + token: 'cur', + refreshToken: 'rt', + tokenProvider: 'openid', + user: { id: 'u' }, + expiresAt: Date.now() + 60 * 60_000, + }; + + const result = await ensureFreshBearer(30_000); + + expect(result).toBe('cur'); + expect(fetchMock).not.toHaveBeenCalled(); + expect(updateSpy).not.toHaveBeenCalled(); + }); + + it('refreshes proactively when expiresAt falls inside the skew window', async () => { + sessionState.data = { + token: 'cur', + refreshToken: 'rt-old', + tokenProvider: 'openid', + user: { id: 'u' }, + expiresAt: Date.now() + 1_000, + }; + fetchMock.mockResolvedValueOnce( + jsonResponse(200, { token: 'cur-fresh', refreshToken: 'rt-rotated', expiresAt: 12345 }), + ); + + const result = await ensureFreshBearer(30_000); + + expect(result).toBe('cur-fresh'); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(updateSpy).toHaveBeenCalledTimes(1); + expect(updateSpy.mock.calls[0][0]).toMatchObject({ + token: 'cur-fresh', + refreshToken: 'rt-rotated', + expiresAt: 12345, + }); + }); + + it('persists the inbound refresh token when the IdP does not rotate', async () => { + sessionState.data = { + token: 'cur', + refreshToken: 'rt-stable', + tokenProvider: 'openid', + user: { id: 'u' }, + expiresAt: Date.now() + 1_000, + }; + fetchMock.mockResolvedValueOnce(jsonResponse(200, { token: 'cur-fresh' })); + + await ensureFreshBearer(30_000); + + expect(updateSpy.mock.calls[0][0]).toMatchObject({ refreshToken: 'rt-stable' }); + }); + + it('returns the stale token when refresh fails', async () => { + sessionState.data = { + token: 'cur', + refreshToken: 'rt', + tokenProvider: 'openid', + user: { id: 'u' }, + expiresAt: Date.now() + 1_000, + }; + fetchMock.mockResolvedValueOnce(jsonResponse(401, { error: 'nope' })); + + const result = await ensureFreshBearer(30_000); + + expect(result).toBe('cur'); + expect(updateSpy).not.toHaveBeenCalled(); + }); + + it('returns undefined when no token and no refreshToken are present', async () => { + sessionState.data = {}; + const result = await ensureFreshBearer(30_000); + expect(result).toBeUndefined(); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +describe('refreshOn401', () => { + it('forces a refresh attempt and persists the new tokenset', async () => { + sessionState.data = { + token: 'cur', + refreshToken: 'rt-old', + tokenProvider: 'openid', + user: { id: 'u' }, + expiresAt: Date.now() + 5 * 60_000, + }; + fetchMock.mockResolvedValueOnce( + jsonResponse(200, { token: 'cur-fresh', refreshToken: 'rt-rotated', expiresAt: 99 }), + ); + + const result = await refreshOn401(); + + expect(result).toBe('cur-fresh'); + expect(updateSpy).toHaveBeenCalledTimes(1); + expect(updateSpy.mock.calls[0][0]).toMatchObject({ + token: 'cur-fresh', + refreshToken: 'rt-rotated', + expiresAt: 99, + }); + }); + + it('returns undefined when there is no refresh token', async () => { + sessionState.data = { token: 'cur' }; + const result = await refreshOn401(); + expect(result).toBeUndefined(); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/server/utils/refresh.ts b/src/server/utils/refresh.ts new file mode 100644 index 0000000..2bec6fe --- /dev/null +++ b/src/server/utils/refresh.ts @@ -0,0 +1,194 @@ +import { z } from 'zod'; +import { getRequestHeader } from '@tanstack/react-start/server'; +import type * as t from '@/types'; +import { useAppSession } from '../session'; +import { getServerApiUrl } from './url'; + +const refreshResponseSchema = z.object({ + token: z.string(), + refreshToken: z.string().optional(), + expiresAt: z.number().optional(), +}); + +export interface RefreshedTokenset { + token: string; + refreshToken?: string; + expiresAt?: number; +} + +/** + * Forwards the deployment's tenant header to the LibreChat backend so that + * `preAuthTenantMiddleware` can scope the refresh lookup. Returns `undefined` + * (no header) when the BFF request didn't carry one — single-tenant deploys. + */ +function readTenantHeader(): string | undefined { + const raw = getRequestHeader('x-tenant-id'); + if (typeof raw !== 'string') return undefined; + const trimmed = raw.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function extractRefreshTokenCookie(response: Response): string | undefined { + const setCookies = response.headers.getSetCookie(); + for (const cookie of setCookies) { + const match = cookie.match(/^refreshToken=([^;]+)/); + if (match) return match[1]; + } + return undefined; +} + +/** + * Calls the LibreChat refresh endpoint matching the session's token provider. + * + * - `openid` sessions hit `/api/admin/oauth/refresh` (body-based) and forward + * the deployment's `X-Tenant-Id` header so the backend's + * `preAuthTenantMiddleware` scopes the user lookup correctly. + * - `librechat` sessions hit the cookie-based `/api/auth/refresh`. + * + * Returns `undefined` on any network or schema failure — callers decide + * whether to clear the session. + */ +export async function refreshAdminToken( + refreshToken: string, + tokenProvider: t.SessionData['tokenProvider'], + userId: string | undefined, +): Promise { + try { + if (tokenProvider === 'openid') { + if (!userId) { + console.warn('[refreshAdminToken] openid refresh requires user id; aborting'); + return undefined; + } + const headers: Record = { 'Content-Type': 'application/json' }; + const tenantId = readTenantHeader(); + if (tenantId) { + headers['X-Tenant-Id'] = tenantId; + } + const response = await fetch(`${getServerApiUrl()}/api/admin/oauth/refresh`, { + method: 'POST', + headers, + body: JSON.stringify({ refresh_token: refreshToken, user_id: userId }), + }); + if (!response.ok) return undefined; + const parsed = refreshResponseSchema.safeParse(await response.json()); + if (!parsed.success) return undefined; + return { + token: parsed.data.token, + refreshToken: parsed.data.refreshToken, + expiresAt: parsed.data.expiresAt, + }; + } + + const response = await fetch(`${getServerApiUrl()}/api/auth/refresh`, { + method: 'POST', + headers: { Cookie: `refreshToken=${refreshToken}` }, + }); + if (!response.ok) return undefined; + const parsed = refreshResponseSchema.safeParse(await response.json()); + if (!parsed.success) return undefined; + return { + token: parsed.data.token, + refreshToken: extractRefreshTokenCookie(response), + }; + } catch (error) { + console.warn('[refreshAdminToken] Token refresh request failed:', error); + return undefined; + } +} + +const inFlight = new Map>(); + +/** + * Build the dedupe key from every discriminator that the upstream refresh + * actually depends on: token provider, user identity, tenant header, and the + * refresh token itself. Keying on `refreshToken` alone would conflate two + * concurrent calls that happen to share a token string but differ by user or + * tenant — the second caller would receive the first caller's bearer and + * persist it into the wrong session. + */ +function buildDedupeKey( + refreshToken: string, + tokenProvider: t.SessionData['tokenProvider'], + userId: string | undefined, + tenantId: string | undefined, +): string { + return [tokenProvider ?? '', userId ?? '', tenantId ?? '', refreshToken].join('\u0000'); +} + +/** + * Module-scoped dedupe so two concurrent React Query subscribers in a single + * BFF process don't both consume a rotating refresh token (which would + * invalidate one of them). + */ +export function refreshAdminTokenDeduped( + refreshToken: string, + tokenProvider: t.SessionData['tokenProvider'], + userId: string | undefined, +): Promise { + const key = buildDedupeKey(refreshToken, tokenProvider, userId, readTenantHeader()); + const existing = inFlight.get(key); + if (existing) return existing; + const pending = refreshAdminToken(refreshToken, tokenProvider, userId).finally(() => { + inFlight.delete(key); + }); + inFlight.set(key, pending); + return pending; +} + +/** + * Refresh the bearer if the session is missing one or its `expiresAt` falls + * within `skewMs` of now. Persists the rotated refresh token (if any) to the + * session and returns the bearer the caller should use. Returns `undefined` + * when no token is available and refresh either failed or is impossible. + */ +export async function ensureFreshBearer(skewMs: number): Promise { + const session = await useAppSession(); + const { token, expiresAt, refreshToken, tokenProvider, user } = session.data; + + if (!refreshToken) { + return token; + } + + const now = Date.now(); + const needsRefresh = !token || (typeof expiresAt === 'number' && expiresAt - now <= skewMs); + if (!needsRefresh) { + return token; + } + + const refreshed = await refreshAdminTokenDeduped(refreshToken, tokenProvider, user?.id); + if (!refreshed) { + return token; + } + + await session.update({ + token: refreshed.token, + refreshToken: refreshed.refreshToken ?? refreshToken, + expiresAt: refreshed.expiresAt, + lastVerified: now, + lastActivity: now, + }); + return refreshed.token; +} + +/** + * After a 401 from a downstream admin call, force a refresh attempt regardless + * of `expiresAt`. Returns the new bearer or `undefined` if refresh failed. + */ +export async function refreshOn401(): Promise { + const session = await useAppSession(); + const { refreshToken, tokenProvider, user } = session.data; + if (!refreshToken) return undefined; + + const refreshed = await refreshAdminTokenDeduped(refreshToken, tokenProvider, user?.id); + if (!refreshed) return undefined; + + const now = Date.now(); + await session.update({ + token: refreshed.token, + refreshToken: refreshed.refreshToken ?? refreshToken, + expiresAt: refreshed.expiresAt, + lastVerified: now, + lastActivity: now, + }); + return refreshed.token; +} diff --git a/src/server/utils/url.ts b/src/server/utils/url.ts new file mode 100644 index 0000000..3728fbe --- /dev/null +++ b/src/server/utils/url.ts @@ -0,0 +1,17 @@ +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(); +} diff --git a/src/types/server.ts b/src/types/server.ts index 11718fc..74ac34e 100644 --- a/src/types/server.ts +++ b/src/types/server.ts @@ -7,6 +7,8 @@ export interface SessionData { token?: string; refreshToken?: string; tokenProvider?: 'librechat' | 'openid'; + /** Absolute expiry of `token` (ms epoch). Drives proactive refresh. */ + expiresAt?: number; lastVerified?: number; lastActivity?: number; codeVerifier?: string; @@ -28,4 +30,5 @@ export interface OAuthExchangeResponse { token: string; refreshToken?: string; user: SerializableUser; + expiresAt?: number; }