mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-27 04:07:05 +00:00
📈 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:
parent
bc5a3f502f
commit
fbc990f684
12 changed files with 439 additions and 24 deletions
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue