diff --git a/src/server/auth.oauth.test.ts b/src/server/auth.oauth.test.ts index a61f39b..1cb7fd6 100644 --- a/src/server/auth.oauth.test.ts +++ b/src/server/auth.oauth.test.ts @@ -40,9 +40,10 @@ vi.mock('./utils/url', () => ({ getServerApiUrl: () => 'http://librechat.test', })); -vi.mock('./utils/refresh', () => ({ +const { refreshAdminTokenDeduped } = vi.hoisted(() => ({ refreshAdminTokenDeduped: vi.fn(), })); +vi.mock('./utils/refresh', () => ({ refreshAdminTokenDeduped })); import { adminLoginFn, @@ -170,6 +171,32 @@ describe('verifyAdminTokenFn', () => { expect(updateSession).toHaveBeenCalledWith({ lastActivity: expect.any(Number) }); }); + it('clears an openid session when refresh reports TOKEN_REUSE_DISABLED after a 401', async () => { + const user = { id: 'user-5', role: 'ADMIN', email: 'openid@example.com' }; + sessionState.data = { + user, + token: 'stale-jwt', + refreshToken: 'rt-unusable', + tokenProvider: 'openid', + lastVerified: 0, + lastActivity: Date.now(), + }; + fetchMock.mockResolvedValueOnce(jsonResponse(401, {})); + refreshAdminTokenDeduped.mockResolvedValueOnce({ kind: 'reuse_disabled' }); + + const result = await verifyAdminTokenFn(); + + expect(result).toEqual({ valid: false, error: 'Session is no longer valid' }); + expect(refreshAdminTokenDeduped).toHaveBeenCalledWith('rt-unusable', 'openid', 'user-5'); + expect(updateSession).toHaveBeenCalledWith( + expect.objectContaining({ + token: undefined, + refreshToken: undefined, + user: undefined, + }), + ); + }); + 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 = { @@ -243,6 +270,32 @@ describe('oauthExchangeFn', () => { ); }); + it('stores no refresh token when the backend omits refreshToken from the exchange response', async () => { + sessionState.data = { codeVerifier: 'verifier-456' }; + requestHeaders.set('origin', 'http://admin.test'); + fetchMock.mockResolvedValueOnce( + jsonResponse(200, { + token: 'jwt-token-no-refresh', + expiresAt: 654321, + user: { id: 'user-7', role: 'ADMIN', email: 'no-refresh@example.com' }, + }), + ); + + const result = await oauthExchangeFn({ data: { code: 'c'.repeat(64) } }); + + expect(result).toEqual({ + error: false, + user: { id: 'user-7', role: 'ADMIN', email: 'no-refresh@example.com' }, + }); + expect(updateSession).toHaveBeenCalledWith( + expect.objectContaining({ + token: 'jwt-token-no-refresh', + refreshToken: undefined, + tokenProvider: 'openid', + }), + ); + }); + it('does not consume the one-time LibreChat exchange code when the PKCE verifier was lost', async () => { sessionState.data = {}; diff --git a/src/server/auth.ts b/src/server/auth.ts index b2f405a..accc91e 100644 --- a/src/server/auth.ts +++ b/src/server/auth.ts @@ -212,22 +212,22 @@ export const verifyAdminTokenFn = createServerFn({ method: 'GET' }).handler(asyn } if (response.status === 401) { if (refreshToken) { - const refreshed = await refreshAdminTokenDeduped( - refreshToken, - tokenProvider, - user.id, - ); - if (refreshed) { + const outcome = await refreshAdminTokenDeduped(refreshToken, tokenProvider, user.id); + if (outcome.kind === 'reuse_disabled') { + await clearSession(session); + return { valid: false, error: 'Session is no longer valid' }; + } + if (outcome.kind === 'success') { const refreshedSession = { - token: refreshed.token, - refreshToken: refreshed.refreshToken ?? refreshToken, - expiresAt: refreshed.expiresAt, + token: outcome.tokens.token, + refreshToken: outcome.tokens.refreshToken ?? refreshToken, + expiresAt: outcome.tokens.expiresAt, lastVerified: now, lastActivity: now, }; try { const reVerify = await fetch(`${getServerApiUrl()}/api/admin/verify`, { - headers: { Authorization: `Bearer ${refreshed.token}` }, + headers: { Authorization: `Bearer ${outcome.tokens.token}` }, }); if (reVerify.ok) { await session.update(refreshedSession); @@ -429,7 +429,7 @@ export const oauthExchangeFn = createServerFn({ method: 'POST' }) await session.update({ user: exchangeData.user, token: exchangeData.token, - refreshToken: exchangeData.refreshToken ?? extractCookieValue(response, 'refreshToken'), + refreshToken: exchangeData.refreshToken, tokenProvider: 'openid', expiresAt: exchangeData.expiresAt, lastVerified: now, diff --git a/src/server/utils/refresh.test.ts b/src/server/utils/refresh.test.ts index 457dcc6..15b1127 100644 --- a/src/server/utils/refresh.test.ts +++ b/src/server/utils/refresh.test.ts @@ -112,7 +112,7 @@ describe('refreshAdminTokenDeduped', () => { const [resA, resB] = await Promise.all([a, b]); expect(resA).toEqual(resB); - expect(resA?.token).toBe('new-jwt'); + expect(resA.kind === 'success' && resA.tokens.token).toBe('new-jwt'); }); it('does not coalesce when userId differs even with the same refresh token', async () => { @@ -126,8 +126,8 @@ describe('refreshAdminTokenDeduped', () => { 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'); + expect(resA.kind === 'success' && resA.tokens.token).toBe('jwt-user-1'); + expect(resB.kind === 'success' && resB.tokens.token).toBe('jwt-user-2'); }); it('does not coalesce when tokenProvider differs', async () => { @@ -141,8 +141,8 @@ describe('refreshAdminTokenDeduped', () => { const [resA, resB] = await Promise.all([a, b]); expect(fetchMock).toHaveBeenCalledTimes(2); - expect(resA?.token).toBe('jwt-openid'); - expect(resB?.token).toBe('jwt-librechat'); + expect(resA.kind === 'success' && resA.tokens.token).toBe('jwt-openid'); + expect(resB.kind === 'success' && resB.tokens.token).toBe('jwt-librechat'); }); it('does not coalesce when tenant header differs between concurrent calls', async () => { @@ -158,8 +158,26 @@ describe('refreshAdminTokenDeduped', () => { 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'); + expect(resA.kind === 'success' && resA.tokens.token).toBe('jwt-tenant-a'); + expect(resB.kind === 'success' && resB.tokens.token).toBe('jwt-tenant-b'); + }); +}); + +describe('refreshAdminToken — reuse-disabled signal', () => { + it('returns kind:"reuse_disabled" when backend rejects with 403 TOKEN_REUSE_DISABLED', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(403, { error_code: 'TOKEN_REUSE_DISABLED' })); + + const result = await refreshAdminToken('rt', 'openid', 'user-1'); + + expect(result).toEqual({ kind: 'reuse_disabled' }); + }); + + it('returns kind:"failed" for a plain 403 without the reuse-disabled error_code', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(403, { error_code: 'FORBIDDEN' })); + + const result = await refreshAdminToken('rt', 'openid', 'user-1'); + + expect(result).toEqual({ kind: 'failed' }); }); }); @@ -241,6 +259,23 @@ describe('ensureFreshBearer', () => { expect(result).toBeUndefined(); expect(fetchMock).not.toHaveBeenCalled(); }); + + it('clears the stored refresh token when backend reports TOKEN_REUSE_DISABLED', async () => { + sessionState.data = { + token: 'cur', + refreshToken: 'rt-unusable', + tokenProvider: 'openid', + user: { id: 'u' }, + expiresAt: Date.now() + 1_000, + }; + fetchMock.mockResolvedValueOnce(jsonResponse(403, { error_code: 'TOKEN_REUSE_DISABLED' })); + + const result = await ensureFreshBearer(30_000); + + expect(result).toBe('cur'); + expect(updateSpy).toHaveBeenCalledTimes(1); + expect(updateSpy.mock.calls[0][0]).toEqual({ refreshToken: undefined }); + }); }); describe('refreshOn401', () => { @@ -273,4 +308,19 @@ describe('refreshOn401', () => { expect(result).toBeUndefined(); expect(fetchMock).not.toHaveBeenCalled(); }); + + it('clears the stored refresh token on TOKEN_REUSE_DISABLED and returns undefined', async () => { + sessionState.data = { + token: 'cur', + refreshToken: 'rt-unusable', + tokenProvider: 'openid', + user: { id: 'u' }, + }; + fetchMock.mockResolvedValueOnce(jsonResponse(403, { error_code: 'TOKEN_REUSE_DISABLED' })); + + const result = await refreshOn401(); + + expect(result).toBeUndefined(); + expect(updateSpy).toHaveBeenCalledWith({ refreshToken: undefined }); + }); }); diff --git a/src/server/utils/refresh.ts b/src/server/utils/refresh.ts index 2bec6fe..c719639 100644 --- a/src/server/utils/refresh.ts +++ b/src/server/utils/refresh.ts @@ -16,6 +16,21 @@ export interface RefreshedTokenset { expiresAt?: number; } +/** + * Outcome of an upstream refresh attempt. + * + * `reuse_disabled` is emitted when the LibreChat backend rejects + * `/api/admin/oauth/refresh` with `403 TOKEN_REUSE_DISABLED` — the deployment + * has `OPENID_REUSE_TOKENS=false`, so any stored IdP refresh token is + * unusable and must be cleared instead of retried. + */ +export type RefreshOutcome = + | { kind: 'success'; tokens: RefreshedTokenset } + | { kind: 'reuse_disabled' } + | { kind: 'failed' }; + +const TOKEN_REUSE_DISABLED_CODE = 'TOKEN_REUSE_DISABLED'; + /** * Forwards the deployment's tenant header to the LibreChat backend so that * `preAuthTenantMiddleware` can scope the refresh lookup. Returns `undefined` @@ -52,12 +67,12 @@ export async function refreshAdminToken( refreshToken: string, tokenProvider: t.SessionData['tokenProvider'], userId: string | undefined, -): Promise { +): Promise { try { if (tokenProvider === 'openid') { if (!userId) { console.warn('[refreshAdminToken] openid refresh requires user id; aborting'); - return undefined; + return { kind: 'failed' }; } const headers: Record = { 'Content-Type': 'application/json' }; const tenantId = readTenantHeader(); @@ -69,13 +84,21 @@ export async function refreshAdminToken( headers, body: JSON.stringify({ refresh_token: refreshToken, user_id: userId }), }); - if (!response.ok) return undefined; + if (!response.ok) { + if (response.status === 403 && (await isReuseDisabled(response))) { + return { kind: 'reuse_disabled' }; + } + return { kind: 'failed' }; + } const parsed = refreshResponseSchema.safeParse(await response.json()); - if (!parsed.success) return undefined; + if (!parsed.success) return { kind: 'failed' }; return { - token: parsed.data.token, - refreshToken: parsed.data.refreshToken, - expiresAt: parsed.data.expiresAt, + kind: 'success', + tokens: { + token: parsed.data.token, + refreshToken: parsed.data.refreshToken, + expiresAt: parsed.data.expiresAt, + }, }; } @@ -83,20 +106,39 @@ export async function refreshAdminToken( method: 'POST', headers: { Cookie: `refreshToken=${refreshToken}` }, }); - if (!response.ok) return undefined; + if (!response.ok) return { kind: 'failed' }; const parsed = refreshResponseSchema.safeParse(await response.json()); - if (!parsed.success) return undefined; + if (!parsed.success) return { kind: 'failed' }; return { - token: parsed.data.token, - refreshToken: extractRefreshTokenCookie(response), + kind: 'success', + tokens: { + token: parsed.data.token, + refreshToken: extractRefreshTokenCookie(response), + }, }; } catch (error) { console.warn('[refreshAdminToken] Token refresh request failed:', error); - return undefined; + return { kind: 'failed' }; + } +} + +const errorCodeSchema = z.object({ error_code: z.string().optional() }); + +/** + * The backend signals `OPENID_REUSE_TOKENS=false` via a 403 with an + * `error_code` of `TOKEN_REUSE_DISABLED`. Payload parsing is tolerant so a + * malformed body still degrades to a plain failure rather than throwing. + */ +async function isReuseDisabled(response: Response): Promise { + try { + const parsed = errorCodeSchema.safeParse(await response.clone().json()); + return parsed.success && parsed.data.error_code === TOKEN_REUSE_DISABLED_CODE; + } catch { + return false; } } -const inFlight = new Map>(); +const inFlight = new Map>(); /** * Build the dedupe key from every discriminator that the upstream refresh @@ -124,7 +166,7 @@ export function refreshAdminTokenDeduped( refreshToken: string, tokenProvider: t.SessionData['tokenProvider'], userId: string | undefined, -): Promise { +): Promise { const key = buildDedupeKey(refreshToken, tokenProvider, userId, readTenantHeader()); const existing = inFlight.get(key); if (existing) return existing; @@ -155,19 +197,23 @@ export async function ensureFreshBearer(skewMs: number): Promise { const { refreshToken, tokenProvider, user } = session.data; if (!refreshToken) return undefined; - const refreshed = await refreshAdminTokenDeduped(refreshToken, tokenProvider, user?.id); - if (!refreshed) return undefined; + const outcome = await refreshAdminTokenDeduped(refreshToken, tokenProvider, user?.id); + if (outcome.kind === 'reuse_disabled') { + await session.update({ refreshToken: undefined }); + return undefined; + } + if (outcome.kind !== 'success') return undefined; const now = Date.now(); await session.update({ - token: refreshed.token, - refreshToken: refreshed.refreshToken ?? refreshToken, - expiresAt: refreshed.expiresAt, + token: outcome.tokens.token, + refreshToken: outcome.tokens.refreshToken ?? refreshToken, + expiresAt: outcome.tokens.expiresAt, lastVerified: now, lastActivity: now, }); - return refreshed.token; + return outcome.tokens.token; }