fix: address OBO review findings

This commit is contained in:
Danny Avila 2026-08-29 12:51:04 -04:00
parent 37657acc13
commit d08c82f0d7
14 changed files with 226 additions and 66 deletions

View file

@ -1,5 +1,6 @@
const cookies = require('cookie');
const jwt = require('jsonwebtoken');
const crypto = require('node:crypto');
const openIdClient = require('openid-client');
const { logger } = require('@librechat/data-schemas');
const {
@ -74,7 +75,7 @@ const sanitizeUserForAuthResponse = (user) => {
return safeUser;
};
const getValidOpenIDReuseUserId = (parsedCookies) => {
const getValidOpenIDReuseUserId = (parsedCookies, refreshToken) => {
const openidUserId = parsedCookies.openid_user_id;
if (!openidUserId || !process.env.JWT_REFRESH_SECRET) {
return null;
@ -82,9 +83,17 @@ const getValidOpenIDReuseUserId = (parsedCookies) => {
try {
const payload = jwt.verify(openidUserId, process.env.JWT_REFRESH_SECRET);
return typeof payload === 'object' && payload != null && typeof payload.id === 'string'
? payload.id
: null;
if (typeof payload !== 'object' || payload == null || typeof payload.id !== 'string') {
return null;
}
if (refreshToken == null) {
return payload.id;
}
if (typeof payload.refreshTokenHash !== 'string') {
return null;
}
const refreshTokenHash = crypto.createHash('sha256').update(refreshToken).digest('base64url');
return payload.refreshTokenHash === refreshTokenHash ? payload.id : null;
} catch {
return null;
}
@ -368,7 +377,7 @@ const refreshController = async (req, res) => {
*/
if (isInvalidGrantError(error) && refreshToken) {
// Bridge lookup uses the signed user-id cookie because /refresh is unauthenticated.
const userId = getValidOpenIDReuseUserId(parsedCookies);
const userId = getValidOpenIDReuseUserId(parsedCookies, refreshToken);
if (userId) {
try {
const bridgeUser = await getUserById(userId, AUTH_REFRESH_USER_PROJECTION);

View file

@ -70,6 +70,7 @@ jest.mock('@librechat/api', () => ({
}),
}));
const { createHash } = require('node:crypto');
const openIdClient = require('openid-client');
const jwt = require('jsonwebtoken');
const { logger } = require('@librechat/data-schemas');
@ -251,8 +252,19 @@ describe('refreshController OpenID path', () => {
idpSigningSecret,
);
const makeSignedUserId = (id = 'user-db-id', options = { expiresIn: '1h' }) =>
jwt.sign({ id }, process.env.JWT_REFRESH_SECRET, options);
const makeSignedUserId = (
id = 'user-db-id',
options = { expiresIn: '1h' },
refreshToken = 'stored-refresh',
) =>
jwt.sign(
{
id,
refreshTokenHash: createHash('sha256').update(refreshToken).digest('base64url'),
},
process.env.JWT_REFRESH_SECRET,
options,
);
const setOpenIDReuseCookies = (signedUserId = makeSignedUserId()) => {
req.headers.cookie = [
@ -935,6 +947,30 @@ describe('refreshController OpenID path', () => {
expect(res.status).toHaveBeenCalledWith(403);
});
it('does not use the bridge when the signed marker belongs to another refresh token', async () => {
setOpenIDReuseCookies(makeSignedUserId('user-db-id', { expiresIn: '1h' }, 'different-refresh'));
openIdClient.refreshTokenGrant.mockRejectedValue(new Error('invalid_grant'));
await refreshController(req, res);
expect(getUserById).not.toHaveBeenCalled();
expect(getRefreshTokenBridge).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
});
it('does not use the bridge when the signed marker lacks a refresh-token binding', async () => {
setOpenIDReuseCookies(
jwt.sign({ id: 'user-db-id' }, process.env.JWT_REFRESH_SECRET, { expiresIn: '1h' }),
);
openIdClient.refreshTokenGrant.mockRejectedValue(new Error('invalid_grant'));
await refreshController(req, res);
expect(getUserById).not.toHaveBeenCalled();
expect(getRefreshTokenBridge).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
});
it('recovers stale refresh-token cookies and keeps a short grace bridge', async () => {
setOpenIDReuseCookies();
req.session = {};

View file

@ -21,6 +21,7 @@ const {
shouldUseSecureCookie,
setRefreshTokenCookie,
setOpenIDMarkerCookies,
normalizeExpiresIn,
createOpenIDSessionIdentity,
resolveAppConfigForUser,
} = require('@librechat/api');
@ -858,9 +859,10 @@ const setOpenIDAuthTokens = (
* always trigger a redundant inline refresh whenever the IdP issues
* opaque access tokens (e.g. Microsoft Graph audiences).
*/
if (typeof tokenset.expires_in === 'number') {
const accessTokenExpiresIn = normalizeExpiresIn(tokenset.expires_in);
if (accessTokenExpiresIn != null) {
sessionOpenidTokens.accessTokenExpiresAt =
Math.floor(Date.now() / 1000) + tokenset.expires_in;
Math.floor(Date.now() / 1000) + accessTokenExpiresIn;
}
req.session.openidTokens = sessionOpenidTokens;
} else {

View file

@ -53,6 +53,12 @@ jest.mock(
});
}
}),
normalizeExpiresIn: (value) => {
const normalized = typeof value === 'string' && value.trim() ? Number(value) : value;
return typeof normalized === 'number' && Number.isFinite(normalized)
? normalized
: undefined;
},
resolveAppConfigForUser: jest.fn(async (_getAppConfig, _user) => ({})),
createOpenIDSessionIdentity: jest.fn(
({ user, userId, openidSubject, tenantId, openidIssuer }) => {
@ -381,6 +387,24 @@ describe('setOpenIDAuthTokens', () => {
expect(persisted).toBeLessThanOrEqual(beforeSec + 3610);
});
it('should persist accessTokenExpiresAt when tokenset.expires_in is a numeric string', () => {
const tokenset = {
id_token: 'the-id-token',
access_token: 'the-access-token',
refresh_token: 'the-refresh-token',
expires_in: '3600',
};
const req = mockRequest();
const res = mockResponse();
const beforeSec = Math.floor(Date.now() / 1000);
setOpenIDAuthTokens(tokenset, req, res, 'user-123');
expect(req.session.openidTokens.accessTokenExpiresAt).toBeGreaterThanOrEqual(
beforeSec + 3590,
);
});
it('should NOT persist accessTokenExpiresAt when tokenset.expires_in is missing', () => {
const tokenset = {
id_token: 'the-id-token',

View file

@ -14,6 +14,7 @@ const {
setRefreshTokenCookie,
setOpenIDMarkerCookies,
storeOpenIdSession,
normalizeExpiresIn,
} = require('@librechat/api');
const { upsertSession, deleteSession } = require('~/models');
const { getOpenIdConfig } = require('~/strategies/openidStrategy');
@ -511,8 +512,9 @@ async function performIdpRefreshGrant(req, res, user, tokenPreference, identityC
* freshness check will correctly fall through to refresh.
*/
let nextAccessTokenExp = null;
if (typeof tokenset.expires_in === 'number') {
nextAccessTokenExp = Math.floor(Date.now() / 1000) + tokenset.expires_in;
const accessTokenExpiresIn = normalizeExpiresIn(tokenset.expires_in);
if (accessTokenExpiresIn != null) {
nextAccessTokenExp = Math.floor(Date.now() / 1000) + accessTokenExpiresIn;
} else {
nextAccessTokenExp = decodeJwtExp(tokenset.access_token);
}

View file

@ -108,6 +108,10 @@ jest.mock('@librechat/api', () => ({
res.cookie('openid_user_id', `signed:${userId}`, { expires });
}
}),
normalizeExpiresIn: (value) => {
const normalized = typeof value === 'string' && value.trim() ? Number(value) : value;
return typeof normalized === 'number' && Number.isFinite(normalized) ? normalized : undefined;
},
storeOpenIdSession: jest.fn(),
}));
jest.mock('~/models', () => ({
@ -1422,6 +1426,27 @@ describe('OpenIDSessionRefresh', () => {
expect(persistedExp).toBeLessThanOrEqual(beforeSec + 3610);
});
it('persists accessTokenExpiresAt when the refreshed expires_in is a numeric string', async () => {
const expiredExp = Math.floor(Date.now() / 1000) - 60;
const sessionTokens = {
accessToken: 'opaque-stale',
idToken: makeJwt(expiredExp),
refreshToken: 'rt-string-expiry',
};
openIdClient.refreshTokenGrant.mockResolvedValueOnce({
access_token: 'opaque-fresh',
expires_in: '3600',
});
const req = buildReq(sessionTokens);
const beforeSec = Math.floor(Date.now() / 1000);
await refreshOpenIDSession(req, undefined, makeOpenIdUser(), 'access_token');
expect(req.session.openidTokens.accessTokenExpiresAt).toBeGreaterThanOrEqual(
beforeSec + 3590,
);
});
it('drops a stale accessTokenExpiresAt when the new tokenset has neither expires_in nor a JWT access_token', async () => {
const expiredExp = Math.floor(Date.now() / 1000) - 60;
const sessionTokens = {

View file

@ -1154,6 +1154,8 @@ async function loadToolDefinitionsWrapper({
userMCPAuthMap,
requestBody: runtimeRequestBody,
requestScopedConnections,
upstreamTokenProvider,
oboIdentityContext,
});
rememberMCPAvailableTools(serverName, result?.availableTools);

View file

@ -69,6 +69,12 @@ jest.mock('@librechat/api', () => ({
['AGENT_EXPECTED_MCP_TOOLS_UNAVAILABLE', 'resource_recovery_required'].includes(error?.code),
loadToolDefinitions: (...args) => mockLoadToolDefinitions(...args),
getUserMCPAuthMap: (...args) => mockGetUserMCPAuthMap(...args),
createAuthIdentityContext: ({ user, tenantId }) => ({
appUserId: user?._id?.toString?.() ?? user?.id,
openidSubject: user?.openidId,
tenantId: tenantId ?? user?.tenantId,
openidIssuer: user?.openidIssuer,
}),
sendEvent: (...args) => mockSendEvent(...args),
GenerationJobManager: {
emitChunk: (...args) => mockEmitChunk(...args),
@ -2069,6 +2075,56 @@ describe('ToolService - Action Capability Gating', () => {
);
});
it('forwards OBO context through forced MCP catalog refreshes', async () => {
const serverName = 'OBO-Refresh';
const mcpTool = `search${Constants.mcp_delimiter}${serverName}`;
const capabilities = [AgentCapabilities.tools];
const req = createMockReq(capabilities);
req.user = {
id: 'user_123',
provider: 'openid',
openidId: 'oidc-sub-123',
tenantId: 'tenant-1',
openidIssuer: 'https://issuer.example.com',
};
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
mockGetServerConfig.mockResolvedValue({
type: 'streamable-http',
url: 'https://mcp.example.com/obo',
obo: { scopes: 'api://obo/Mcp.Tools.ReadWrite' },
});
mockLoadToolDefinitions.mockImplementation(async (params, dependencies) => {
await dependencies.refreshMCPServerTools(params.userId, serverName);
return {
toolDefinitions: [],
toolRegistry: new Map(),
hasDeferredTools: false,
};
});
reinitMCPServer.mockResolvedValue({ availableTools: {} });
await loadAgentTools({
req,
agent: { id: 'agent_123', tools: [mcpTool] },
definitionsOnly: true,
});
expect(reinitMCPServer).toHaveBeenCalledWith(
expect.objectContaining({
serverName,
forceNew: true,
upstreamTokenProvider: expect.any(Function),
oboIdentityContext: expect.objectContaining({
appUserId: 'user_123',
openidSubject: 'oidc-sub-123',
tenantId: 'tenant-1',
openidIssuer: 'https://issuer.example.com',
}),
}),
);
});
it('returns run-scoped MCP tool definitions for request-scoped servers', async () => {
const serverName = 'ClickHouse';
const mcpTool = `list_tables${Constants.mcp_delimiter}${serverName}`;

View file

@ -125,6 +125,21 @@ describe('resolveOboToken', () => {
expect(mockResolver).not.toHaveBeenCalled();
});
it('marks transient session refresh failures as retryable', async () => {
const failingProvider: UpstreamTokenProvider = jest
.fn()
.mockRejectedValue(Object.assign(new Error('service unavailable'), { status: 503 }));
await expect(
resolveOboToken(mockUser as IUser, oboConfig, mockResolver, failingProvider),
).rejects.toMatchObject({
reason: 'session_refresh_failed',
retryable: true,
userMessage: 'Temporary sign-in session refresh failure.',
});
expect(mockResolver).not.toHaveBeenCalled();
});
it('throws missing_upstream_token when isOpenIDTokenValid returns false (live token expired)', async () => {
mockIsOpenIDTokenValid.mockReturnValue(false);

View file

@ -202,10 +202,13 @@ export async function resolveOboToken(
liveTokens = await upstreamTokenProvider();
} catch (error) {
logger.error('[OBO] Upstream session refresh failed:', error);
const retryable = isRetryableOboExchangeError(error);
throw new OboTokenResolutionError(
'session_refresh_failed',
'Your sign-in session expired and could not be refreshed. Please sign in again.',
false,
retryable
? 'Temporary sign-in session refresh failure.'
: 'Your sign-in session expired and could not be refreshed. Please sign in again.',
retryable,
error,
);
}

View file

@ -4,7 +4,7 @@ import type * as t from '~/mcp/types';
import { registryStatusCache as statusCache } from './cache/RegistryStatusCache';
import { resolveServerInstructions, sanitizeUrlForLogging } from '~/mcp/utils';
import { MCPServersRegistry } from './MCPServersRegistry';
import { isEnabled, withTimeout } from '~/utils';
import { withTimeout } from '~/utils';
import { isLeader } from '~/cluster';
const DEFAULT_MCP_INIT_TIMEOUT_MS = 30_000;
@ -186,22 +186,6 @@ export class MCPServersInitializer {
);
logger.info(`${prefix} Initialized in: ${config.initDuration ?? 'N/A'}ms`);
logger.info(`${prefix} -------------------------------------------------┘`);
if (config.obo != null && !isEnabled(process.env.OPENID_REUSE_TOKENS)) {
/**
* OBO requires `req.session.openidTokens` and `user.federatedTokens`,
* both of which are only populated when `OPENID_REUSE_TOKENS=true` (see
* `socialLogins.js` and `openIdJwtStrategy.js`). Without reuse, every
* OBO tool call will fail at runtime with "No valid OpenID access token
* is available for OBO exchange." Surface this misconfiguration at
* startup so operators don't have to diagnose it from per-call errors.
*/
logger.warn(
`${prefix} OBO is configured on this server but OPENID_REUSE_TOKENS is not enabled. ` +
'OBO token exchange will fail at runtime because user.federatedTokens is never populated. ' +
"Set OPENID_REUSE_TOKENS=true or remove the `obo` block from this server's config.",
);
}
}
private static formatInstructionsForLogging(instructions?: string): string {

View file

@ -534,7 +534,7 @@ describe('MCPServersInitializer', () => {
});
});
describe('OBO + OPENID_REUSE_TOKENS startup warning', () => {
describe('OBO initialization without browser token reuse', () => {
const oboConfigs: t.MCPServers = {
obo_server: {
type: 'streamable-http',
@ -553,7 +553,7 @@ describe('MCPServersInitializer', () => {
}
});
it('warns when OBO is configured and OPENID_REUSE_TOKENS is unset', async () => {
it('does not declare OBO unusable because bearer-auth flows remain valid', async () => {
delete process.env.OPENID_REUSE_TOKENS;
mockInspect.mockImplementationOnce(
async (_n, raw) =>
@ -565,34 +565,9 @@ describe('MCPServersInitializer', () => {
await MCPServersInitializer.initialize(oboConfigs);
const warnCalls = mockLogger.warn.mock.calls.flat().join(' | ');
expect(warnCalls).toMatch(/OBO is configured/);
expect(warnCalls).toMatch(/OPENID_REUSE_TOKENS/);
});
it('does not warn when OPENID_REUSE_TOKENS is enabled', async () => {
process.env.OPENID_REUSE_TOKENS = 'true';
mockInspect.mockImplementationOnce(
async (_n, raw) =>
({
...raw,
requiresOAuth: false,
}) as unknown as t.ParsedServerConfig,
);
await MCPServersInitializer.initialize(oboConfigs);
const warnCalls = mockLogger.warn.mock.calls.flat().join(' | ');
expect(warnCalls).not.toMatch(/OBO is configured/);
});
it('does not warn when no OBO config is present', async () => {
delete process.env.OPENID_REUSE_TOKENS;
await MCPServersInitializer.initialize(testConfigs);
const warnCalls = mockLogger.warn.mock.calls.flat().join(' | ');
expect(warnCalls).not.toMatch(/OBO is configured/);
expect(await registry.getServerConfig('obo_server')).toBeDefined();
});
});
});

View file

@ -19,7 +19,6 @@ beforeAll(async () => {
if (!mongoose.models.OpenIDRefreshFlight) {
mongoose.model<t.IOpenIDRefreshFlight>('OpenIDRefreshFlight', openidRefreshFlightSchema);
}
methods = createOpenIDRefreshFlightMethods(mongoose);
});
afterAll(async () => {
@ -29,16 +28,27 @@ afterAll(async () => {
beforeEach(async () => {
await mongoose.connection.dropDatabase();
/**
* Mutual exclusion between workers rests entirely on the unique `key` index: without it a second
* `create` succeeds instead of raising a duplicate-key error, and every caller believes it won
* the flight. `dropDatabase` takes the indexes with it, and Mongoose only builds them once when
* the model is compiled, so they are rebuilt here rather than left to that race.
*/
await mongoose.models.OpenIDRefreshFlight.createIndexes();
methods = createOpenIDRefreshFlightMethods(mongoose);
});
describe('OpenIDRefreshFlight Methods', () => {
it('creates coordination indexes before the first acquisition', async () => {
await methods.acquireOpenIDRefreshFlight({
key: 'flight-key',
ownerId: 'owner-1',
lockExpiresAt: new Date(Date.now() + 30000),
expiresAt: new Date(Date.now() + 60000),
});
const indexes = await mongoose.models.OpenIDRefreshFlight.listIndexes();
expect(indexes).toEqual(
expect.arrayContaining([
expect.objectContaining({ key: { key: 1 }, unique: true }),
expect.objectContaining({ key: { expiresAt: 1 }, expireAfterSeconds: 0 }),
]),
);
});
it('acquires a new pending flight and returns existing flight to joiners', async () => {
const first = await methods.acquireOpenIDRefreshFlight({
key: 'flight-key',

View file

@ -7,6 +7,7 @@ import type {
OpenIDRefreshFlightQuery,
OpenIDRefreshFlightAcquireResult,
} from '~/types';
import { createIndexesWithRetry } from '~/utils/retry';
import logger from '~/config/winston';
function hasErrorCode(error: unknown): error is { code: number } {
@ -36,12 +37,28 @@ export function createOpenIDRefreshFlightMethods(mongoose: typeof import('mongoo
query: OpenIDRefreshFlightQuery,
) => Promise<IOpenIDRefreshFlight | null>;
} {
let indexesPromise: Promise<void> | null = null;
function ensureIndexes(): Promise<void> {
if (!indexesPromise) {
const OpenIDRefreshFlight = mongoose.models
.OpenIDRefreshFlight as Model<IOpenIDRefreshFlight>;
indexesPromise = createIndexesWithRetry(OpenIDRefreshFlight).catch((error) => {
indexesPromise = null;
throw error;
});
}
return indexesPromise;
}
async function acquireOpenIDRefreshFlight(
data: OpenIDRefreshFlightCreateData,
): Promise<OpenIDRefreshFlightAcquireResult> {
const OpenIDRefreshFlight = mongoose.models.OpenIDRefreshFlight as Model<IOpenIDRefreshFlight>;
const now = new Date();
await ensureIndexes();
try {
const flight = await OpenIDRefreshFlight.create({
...data,