Skip to content
Open
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
55 changes: 54 additions & 1 deletion src/server/auth.oauth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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 = {};

Expand Down
22 changes: 11 additions & 11 deletions src/server/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand Down
64 changes: 57 additions & 7 deletions src/server/utils/refresh.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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 () => {
Expand All @@ -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 () => {
Expand All @@ -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' });
});
});

Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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 });
});
});
102 changes: 76 additions & 26 deletions src/server/utils/refresh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -52,12 +67,12 @@ export async function refreshAdminToken(
refreshToken: string,
tokenProvider: t.SessionData['tokenProvider'],
userId: string | undefined,
): Promise<RefreshedTokenset | undefined> {
): Promise<RefreshOutcome> {
try {
if (tokenProvider === 'openid') {
if (!userId) {
console.warn('[refreshAdminToken] openid refresh requires user id; aborting');
return undefined;
return { kind: 'failed' };
}
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
const tenantId = readTenantHeader();
Expand All @@ -69,34 +84,61 @@ 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,
},
};
}

const response = await fetch(`${getServerApiUrl()}/api/auth/refresh`, {
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<boolean> {
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<string, Promise<RefreshedTokenset | undefined>>();
const inFlight = new Map<string, Promise<RefreshOutcome>>();

/**
* Build the dedupe key from every discriminator that the upstream refresh
Expand Down Expand Up @@ -124,7 +166,7 @@ export function refreshAdminTokenDeduped(
refreshToken: string,
tokenProvider: t.SessionData['tokenProvider'],
userId: string | undefined,
): Promise<RefreshedTokenset | undefined> {
): Promise<RefreshOutcome> {
const key = buildDedupeKey(refreshToken, tokenProvider, userId, readTenantHeader());
const existing = inFlight.get(key);
if (existing) return existing;
Expand Down Expand Up @@ -155,19 +197,23 @@ export async function ensureFreshBearer(skewMs: number): Promise<string | undefi
return token;
}

const refreshed = await refreshAdminTokenDeduped(refreshToken, tokenProvider, user?.id);
if (!refreshed) {
const outcome = await refreshAdminTokenDeduped(refreshToken, tokenProvider, user?.id);
if (outcome.kind === 'reuse_disabled') {
await session.update({ refreshToken: undefined });
return token;
}
if (outcome.kind !== 'success') {
return token;
}

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;
}

/**
Expand All @@ -179,16 +225,20 @@ export async function refreshOn401(): Promise<string | undefined> {
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;
}