📈 fix: Isolate RUM Telemetry Proxy Auth from App Auth (#13765)

* fix(rum): isolate telemetry proxy auth

* feat(rum): track proxy error metrics

* refactor(rum): simplify proxy auth strategy flow

* test(rum): clarify proxy success metric assertion

* test(metrics): use typed supertest import

* test(metrics): add local supertest types

* test(metrics): keep supertest types local

* test(metrics): use official supertest types

* fix(rum): log proxy auth strategy errors

* fix(rum): classify proxy auth errors in metrics

* style(rum): sort telemetry metric imports

* ci: mention import sort check command

* ci: show targeted import sort example
This commit is contained in:
Ravi Kumar L 2026-06-15 12:49:44 -04:00 committed by GitHub
parent bc5a3f502f
commit fbc990f684
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 439 additions and 24 deletions

View file

@ -213,6 +213,7 @@ jest.mock('@librechat/api', () => {
normalizeContextValue(req.headers?.['x-correlation-id']);
return {
isEnabled: jest.fn(() => false),
recordRumProxyRequest: jest.fn(),
getAuthFailureReason,
getAuthFailureErrorName,
buildSafeAuthLogContext,
@ -235,8 +236,13 @@ jest.mock('@librechat/api', () => {
// ── Helpers ─────────────────────────────────────────────────────────────
const requireJwtAuth = require('../requireJwtAuth');
const { requireRumProxyAuth } = requireJwtAuth;
const { getTenantId, getUserId, logger } = require('@librechat/data-schemas');
const { isEnabled, maybeRefreshCloudFrontAuthCookiesMiddleware } = require('@librechat/api');
const {
isEnabled,
maybeRefreshCloudFrontAuthCookiesMiddleware,
recordRumProxyRequest,
} = require('@librechat/api');
const passport = require('passport');
const jwtSecret = 'test-refresh-secret';
@ -250,7 +256,11 @@ function signedOpenIdUserCookie(userId = 'user-openid') {
}
function mockRes() {
return { status: jest.fn().mockReturnThis(), json: jest.fn().mockReturnThis() };
return {
status: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis(),
end: jest.fn().mockReturnThis(),
};
}
/** Runs requireJwtAuth and returns the tenantId observed inside next(). */
@ -282,6 +292,7 @@ describe('requireJwtAuth tenant context chaining', () => {
logger.info.mockClear();
logger.warn.mockClear();
logger.error.mockClear();
recordRumProxyRequest.mockClear();
passport.authenticate.mockClear();
passport._strategy.mockClear();
if (originalJwtSecret === undefined) {
@ -836,3 +847,141 @@ describe('requireJwtAuth tenant context chaining', () => {
expect(getTenantId()).toBeUndefined();
});
});
describe('requireRumProxyAuth', () => {
const originalJwtSecret = process.env.JWT_REFRESH_SECRET;
beforeEach(() => {
process.env.JWT_REFRESH_SECRET = jwtSecret;
});
afterEach(() => {
mockPassportError = null;
mockRegisteredStrategies = new Set(['jwt']);
isEnabled.mockReturnValue(false);
maybeRefreshCloudFrontAuthCookiesMiddleware.mockClear();
logger.debug.mockClear();
logger.info.mockClear();
logger.warn.mockClear();
logger.error.mockClear();
recordRumProxyRequest.mockClear();
passport.authenticate.mockClear();
passport._strategy.mockClear();
if (originalJwtSecret === undefined) {
delete process.env.JWT_REFRESH_SECRET;
} else {
process.env.JWT_REFRESH_SECRET = originalJwtSecret;
}
});
it('authenticates telemetry with the LibreChat JWT strategy without tenant or cookie refresh middleware', () => {
const req = mockReq({ id: 'user-jwt', tenantId: 'tenant-jwt', role: 'user' });
const res = mockRes();
const next = jest.fn();
requireRumProxyAuth(req, res, next);
expect(passport.authenticate).toHaveBeenCalledWith(
'jwt',
{ session: false },
expect.any(Function),
);
expect(req.authStrategy).toBe('jwt');
expect(maybeRefreshCloudFrontAuthCookiesMiddleware).not.toHaveBeenCalled();
// Success is recorded by the proxy.
expect(recordRumProxyRequest).not.toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
expect(next).toHaveBeenCalled();
});
it('authenticates telemetry with OpenID JWT reuse when the reuse cookie is present', () => {
isEnabled.mockReturnValue(true);
mockRegisteredStrategies.add('openidJwt');
const req = mockReq(undefined, {
headers: { cookie: `token_provider=openid; openid_user_id=${signedOpenIdUserCookie()}` },
_mockStrategies: {
openidJwt: { user: { id: 'user-openid', tenantId: 'tenant-openid', role: 'user' } },
jwt: { user: false, info: { message: 'invalid signature' }, status: 401 },
},
});
const res = mockRes();
const next = jest.fn();
requireRumProxyAuth(req, res, next);
expect(passport.authenticate).toHaveBeenCalledWith(
'openidJwt',
{ session: false },
expect.any(Function),
);
expect(req.authStrategy).toBe('openidJwt');
expect(maybeRefreshCloudFrontAuthCookiesMiddleware).not.toHaveBeenCalled();
expect(recordRumProxyRequest).not.toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
expect(next).toHaveBeenCalled();
});
it('falls back to LibreChat JWT when OpenID JWT telemetry auth fails', () => {
isEnabled.mockReturnValue(true);
mockRegisteredStrategies.add('openidJwt');
const req = mockReq(undefined, {
headers: { cookie: `token_provider=openid; openid_user_id=${signedOpenIdUserCookie()}` },
_mockStrategies: {
openidJwt: {
user: false,
info: { message: 'jwt expired', name: 'TokenExpiredError' },
status: 401,
},
jwt: { user: { id: 'user-openid', tenantId: 'tenant-jwt', role: 'user' } },
},
});
const res = mockRes();
const next = jest.fn();
requireRumProxyAuth(req, res, next);
expect(passport.authenticate).toHaveBeenCalledTimes(2);
expect(req.authStrategy).toBe('jwt');
expect(recordRumProxyRequest).not.toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
expect(next).toHaveBeenCalled();
});
it('drops invalid telemetry auth with 204 instead of returning an app auth error', () => {
const req = mockReq(undefined, {
path: '/v1/traces',
_mockStrategies: {
jwt: {
user: false,
info: { message: 'invalid signature', name: 'JsonWebTokenError' },
status: 401,
},
},
});
const res = mockRes();
const next = jest.fn();
requireRumProxyAuth(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(maybeRefreshCloudFrontAuthCookiesMiddleware).not.toHaveBeenCalled();
expect(recordRumProxyRequest).toHaveBeenCalledWith('traces', 'auth_drop');
expect(res.status).toHaveBeenCalledWith(204);
expect(res.end).toHaveBeenCalled();
});
it('records passport errors separately from ordinary telemetry auth drops', () => {
mockPassportError = new Error('passport unavailable');
const req = mockReq(undefined, { path: '/v1/logs' });
const res = mockRes();
const next = jest.fn();
requireRumProxyAuth(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(logger.warn).not.toHaveBeenCalled();
expect(recordRumProxyRequest).toHaveBeenCalledWith('logs', 'auth_error');
expect(res.status).toHaveBeenCalledWith(204);
expect(res.end).toHaveBeenCalled();
});
});

View file

@ -11,6 +11,7 @@ const requireLdapAuth = require('./requireLdapAuth');
const abortMiddleware = require('./abortMiddleware');
const checkInviteUser = require('./checkInviteUser');
const requireJwtAuth = require('./requireJwtAuth');
const { requireRumProxyAuth } = require('./requireJwtAuth');
const configMiddleware = require('./config/app');
const validateModel = require('./validateModel');
const moderateText = require('./moderateText');
@ -37,6 +38,7 @@ module.exports = {
moderateText,
validateModel,
requireJwtAuth,
requireRumProxyAuth,
setTwoFactorTempUser,
checkInviteUser,
requireLdapAuth,

View file

@ -10,6 +10,7 @@ const {
buildSafeAuthLogContext,
formatAuthLogMessage,
maybeRefreshCloudFrontAuthCookiesMiddleware,
recordRumProxyRequest,
} = require('@librechat/api');
const hasPassportStrategy = (strategy) =>
@ -35,6 +36,45 @@ const getAuthenticatedUserId = (user) => user?.id?.toString?.() ?? user?._id?.to
const refreshCloudFrontCookies =
maybeRefreshCloudFrontAuthCookiesMiddleware ?? ((_req, _res, next) => next());
const getAuthStrategies = (req) => {
const cookieHeader = req.headers.cookie;
const parsedCookies = cookieHeader ? cookies.parse(cookieHeader) : {};
const tokenProvider = parsedCookies.token_provider;
const openidReuseEnabled = isEnabled(process.env.OPENID_REUSE_TOKENS);
const openidJwtAvailable = openidReuseEnabled && hasPassportStrategy('openidJwt');
const openIdReuseUserId = getValidOpenIdReuseUserId(parsedCookies);
const useOpenIdJwt =
tokenProvider === 'openid' && openidJwtAvailable && openIdReuseUserId != null;
return {
tokenProvider,
openidReuseEnabled,
openidJwtAvailable,
openIdReuseUserId,
strategies: useOpenIdJwt ? ['openidJwt', 'jwt'] : ['jwt'],
};
};
const dropRumTelemetry = (res) => {
if (!res.headersSent) {
res.status(204).end();
}
};
// Keep in sync with packages/api/src/rum/proxy.ts; auth drops are recorded before proxy code runs.
const getRumProxyEndpoint = (req) => {
if (req.path === '/v1/traces') {
return 'traces';
}
if (req.path === '/v1/logs') {
return 'logs';
}
return 'unknown';
};
const isOpenIdReuseUser = (strategy, user, openIdReuseUserId) =>
strategy !== 'openidJwt' || getAuthenticatedUserId(user) === openIdReuseUserId;
/**
* Custom Middleware to handle JWT authentication, with support for OpenID token reuse.
* Switches between JWT and OpenID authentication based on cookies and environment settings.
@ -44,15 +84,8 @@ const refreshCloudFrontCookies =
* for downstream Mongoose tenant isolation and structured logging.
*/
const requireJwtAuth = (req, res, next) => {
const cookieHeader = req.headers.cookie;
const parsedCookies = cookieHeader ? cookies.parse(cookieHeader) : {};
const tokenProvider = parsedCookies.token_provider;
const openidReuseEnabled = isEnabled(process.env.OPENID_REUSE_TOKENS);
const openidJwtAvailable = openidReuseEnabled && hasPassportStrategy('openidJwt');
const openIdReuseUserId = getValidOpenIdReuseUserId(parsedCookies);
const useOpenIdJwt =
tokenProvider === 'openid' && openidJwtAvailable && openIdReuseUserId != null;
const strategies = useOpenIdJwt ? ['openidJwt', 'jwt'] : ['jwt'];
const { tokenProvider, openidReuseEnabled, openidJwtAvailable, openIdReuseUserId, strategies } =
getAuthStrategies(req);
const authLogState = {
tokenProvider,
openidReuseEnabled,
@ -162,4 +195,45 @@ const requireJwtAuth = (req, res, next) => {
authenticateWithStrategy(0);
};
const requireRumProxyAuth = (req, res, next) => {
const { openIdReuseUserId, strategies } = getAuthStrategies(req);
const endpoint = getRumProxyEndpoint(req);
let authErrorSeen = false;
const dropTelemetry = () => {
recordRumProxyRequest(endpoint, authErrorSeen ? 'auth_error' : 'auth_drop');
dropRumTelemetry(res);
};
const finishAuthentication = (strategy, user) => {
req.user = user;
req.authStrategy = strategy;
next();
};
let nextStrategyIndex = 0;
const tryNextStrategy = () => {
const strategy = strategies[nextStrategyIndex];
nextStrategyIndex += 1;
if (!strategy) {
dropTelemetry();
return;
}
passport.authenticate(strategy, { session: false }, (err, user) => {
authErrorSeen = authErrorSeen || err != null;
if (err || !user || !isOpenIdReuseUser(strategy, user, openIdReuseUserId)) {
tryNextStrategy();
return;
}
finishAuthentication(strategy, user);
})(req, res, next);
};
tryNextStrategy();
};
module.exports = requireJwtAuth;
module.exports.requireRumProxyAuth = requireRumProxyAuth;

View file

@ -1,12 +1,12 @@
const express = require('express');
const request = require('supertest');
const mockRequireJwtAuth = jest.fn((_req, _res, next) => next());
const mockRequireRumProxyAuth = jest.fn((_req, _res, next) => next());
const mockIsRumProxyEnabled = jest.fn();
const mockProxyRumRequest = jest.fn((_req, res) => res.status(202).send());
jest.mock('~/server/middleware', () => ({
requireJwtAuth: (...args) => mockRequireJwtAuth(...args),
requireRumProxyAuth: (...args) => mockRequireRumProxyAuth(...args),
}));
jest.mock('@librechat/api', () => ({
@ -26,7 +26,7 @@ describe('RUM proxy routes', () => {
});
beforeEach(() => {
mockRequireJwtAuth.mockClear();
mockRequireRumProxyAuth.mockClear();
mockIsRumProxyEnabled.mockReset();
mockProxyRumRequest.mockClear();
});
@ -41,7 +41,7 @@ describe('RUM proxy routes', () => {
expect(response.status).toBe(404);
expect(response.body).toEqual({ message: 'RUM proxy is not configured' });
expect(mockRequireJwtAuth).not.toHaveBeenCalled();
expect(mockRequireRumProxyAuth).not.toHaveBeenCalled();
expect(mockProxyRumRequest).not.toHaveBeenCalled();
});
@ -54,7 +54,20 @@ describe('RUM proxy routes', () => {
.send(Buffer.from('payload'));
expect(response.status).toBe(202);
expect(mockRequireJwtAuth).toHaveBeenCalledTimes(1);
expect(mockRequireRumProxyAuth).toHaveBeenCalledTimes(1);
expect(mockProxyRumRequest).toHaveBeenCalledTimes(1);
});
it('uses RUM-specific auth for logs as well as traces', async () => {
mockIsRumProxyEnabled.mockReturnValue(true);
const response = await request(app)
.post('/api/rum/v1/logs')
.set('Content-Type', 'application/x-protobuf')
.send(Buffer.from('payload'));
expect(response.status).toBe(202);
expect(mockRequireRumProxyAuth).toHaveBeenCalledTimes(1);
expect(mockProxyRumRequest).toHaveBeenCalledTimes(1);
});
});

View file

@ -1,6 +1,6 @@
const express = require('express');
const { getRumProxyBodyLimit, isRumProxyEnabled, proxyRumRequest } = require('@librechat/api');
const { requireJwtAuth } = require('~/server/middleware');
const { requireRumProxyAuth } = require('~/server/middleware');
const router = express.Router();
const rawOtlpBody = express.raw({
@ -16,7 +16,13 @@ function requireRumProxyEnabled(_req, res, next) {
return next();
}
router.post('/v1/traces', requireRumProxyEnabled, requireJwtAuth, rawOtlpBody, proxyRumRequest);
router.post('/v1/logs', requireRumProxyEnabled, requireJwtAuth, rawOtlpBody, proxyRumRequest);
router.post(
'/v1/traces',
requireRumProxyEnabled,
requireRumProxyAuth,
rawOtlpBody,
proxyRumRequest,
);
router.post('/v1/logs', requireRumProxyEnabled, requireRumProxyAuth, rawOtlpBody, proxyRumRequest);
module.exports = router;