mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-06-09 17:31:19 +00:00
🛰️ feat: Add Auth Fallback Observability (#13488)
* feat: add auth fallback observability * refactor: move auth log helpers to api package * test: harden auth log context handling * fix: keep auth logs low cardinality * fix: render auth log context in debug messages * fix: lower plain jwt auth failures to debug
This commit is contained in:
parent
c50b3c58d5
commit
1fa28ec45b
5 changed files with 963 additions and 1 deletions
|
|
@ -43,6 +43,7 @@ jest.mock('@librechat/data-schemas', () => {
|
|||
getTenantId: () => tenantStorage.getStore()?.tenantId,
|
||||
getUserId: () => tenantStorage.getStore()?.userId,
|
||||
getRequestId: () => tenantStorage.getStore()?.requestId,
|
||||
logger: { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() },
|
||||
tenantStorage,
|
||||
};
|
||||
});
|
||||
|
|
@ -53,6 +54,152 @@ jest.mock('@librechat/data-schemas', () => {
|
|||
// primitives. The real implementation is covered by packages/api tenant.spec.ts.
|
||||
jest.mock('@librechat/api', () => {
|
||||
const { tenantStorage } = require('@librechat/data-schemas');
|
||||
const normalizeAuthLogValue = (value) => {
|
||||
if (value == null) {
|
||||
return undefined;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
for (const entry of value) {
|
||||
const normalized = normalizeAuthLogValue(entry);
|
||||
if (normalized) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
return trimmed || undefined;
|
||||
}
|
||||
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
return String(value);
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const normalizeAuthLogContextValue = (value) => {
|
||||
if (value == null) {
|
||||
return undefined;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
const values = value
|
||||
.map((entry) => normalizeAuthLogValue(entry))
|
||||
.filter((entry) => entry !== undefined);
|
||||
return values.length > 0 ? values : undefined;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return normalizeAuthLogValue(value);
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const getAuthFailureField = (source, field) => {
|
||||
if (!source) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof source === 'string') {
|
||||
return field === 'message' ? source : undefined;
|
||||
}
|
||||
if (typeof source === 'object') {
|
||||
try {
|
||||
return source[field];
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const getAuthFailureReason = (err, info, fallback = 'Unauthorized') =>
|
||||
normalizeAuthLogValue(getAuthFailureField(info, 'message')) ??
|
||||
normalizeAuthLogValue(getAuthFailureField(err, 'message')) ??
|
||||
fallback;
|
||||
const getAuthFailureErrorName = (err, info) =>
|
||||
normalizeAuthLogValue(getAuthFailureField(info, 'name')) ??
|
||||
normalizeAuthLogValue(getAuthFailureField(err, 'name'));
|
||||
const getSafeTokenProvider = (tokenProvider) => {
|
||||
const normalized = normalizeAuthLogValue(tokenProvider);
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
return normalized === 'openid' || normalized === 'librechat' ? normalized : 'other';
|
||||
};
|
||||
const normalizeRoutePath = (path) => {
|
||||
if (typeof path === 'string') {
|
||||
return normalizeAuthLogValue(path);
|
||||
}
|
||||
if (Array.isArray(path)) {
|
||||
for (const entry of path) {
|
||||
const normalized = normalizeRoutePath(entry);
|
||||
if (normalized) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const joinRoutePath = (baseUrl, routePath) => {
|
||||
const normalizedRoute = routePath === '/' ? '' : routePath;
|
||||
if (!baseUrl) {
|
||||
return normalizedRoute || '/';
|
||||
}
|
||||
if (!normalizedRoute) {
|
||||
return baseUrl;
|
||||
}
|
||||
return `${baseUrl.replace(/\/$/, '')}/${normalizedRoute.replace(/^\//, '')}`;
|
||||
};
|
||||
const bucketConcretePath = (path) => {
|
||||
const queryless = path?.split('?')[0];
|
||||
if (!queryless) {
|
||||
return undefined;
|
||||
}
|
||||
const segments = queryless.split('/').filter(Boolean);
|
||||
if (segments.length === 0) {
|
||||
return '/';
|
||||
}
|
||||
if (segments[0] === 'api' && segments[1]) {
|
||||
return `/${segments.slice(0, 2).join('/')}`;
|
||||
}
|
||||
return `/${segments[0]}`;
|
||||
};
|
||||
const getRequestPath = (req) => {
|
||||
const baseUrl = normalizeAuthLogValue(req.baseUrl);
|
||||
const routePath = normalizeRoutePath(req.route?.path);
|
||||
if (routePath) {
|
||||
return joinRoutePath(baseUrl, routePath);
|
||||
}
|
||||
if (baseUrl) {
|
||||
return baseUrl;
|
||||
}
|
||||
const path =
|
||||
normalizeAuthLogValue(req.path) ?? normalizeAuthLogValue(req.originalUrl ?? req.url);
|
||||
return bucketConcretePath(path);
|
||||
};
|
||||
const compactAuthLogContext = (log) =>
|
||||
Object.fromEntries(
|
||||
Object.entries(log)
|
||||
.map(([key, value]) => [key, normalizeAuthLogContextValue(value)])
|
||||
.filter(([, value]) => value !== undefined),
|
||||
);
|
||||
const buildSafeAuthLogContext = (req, authState, extra = {}) =>
|
||||
compactAuthLogContext({
|
||||
...extra,
|
||||
request_id:
|
||||
normalizeAuthLogValue(req.requestId) ??
|
||||
normalizeAuthLogValue(req.id) ??
|
||||
normalizeAuthLogValue(req.headers?.['x-request-id']) ??
|
||||
normalizeAuthLogValue(req.headers?.['x-correlation-id']),
|
||||
method: normalizeAuthLogValue(req.method),
|
||||
path: getRequestPath(req),
|
||||
token_provider: getSafeTokenProvider(authState.tokenProvider),
|
||||
openid_reuse_enabled: authState.openidReuseEnabled,
|
||||
openid_jwt_available: authState.openidJwtAvailable,
|
||||
has_openid_reuse_user_id: authState.hasOpenIdReuseUserId,
|
||||
});
|
||||
const formatAuthLogMessage = (message, context) => `${message} ${JSON.stringify(context)}`;
|
||||
const normalizeContextValue = (value) => {
|
||||
const trimmed = value?.trim?.();
|
||||
return trimmed || undefined;
|
||||
|
|
@ -66,6 +213,10 @@ jest.mock('@librechat/api', () => {
|
|||
normalizeContextValue(req.headers?.['x-correlation-id']);
|
||||
return {
|
||||
isEnabled: jest.fn(() => false),
|
||||
getAuthFailureReason,
|
||||
getAuthFailureErrorName,
|
||||
buildSafeAuthLogContext,
|
||||
formatAuthLogMessage,
|
||||
maybeRefreshCloudFrontAuthCookiesMiddleware: jest.fn((req, res, next) => next()),
|
||||
tenantContextMiddleware: (req, res, next) => {
|
||||
const context = {
|
||||
|
|
@ -84,7 +235,7 @@ jest.mock('@librechat/api', () => {
|
|||
// ── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
const requireJwtAuth = require('../requireJwtAuth');
|
||||
const { getTenantId, getUserId } = require('@librechat/data-schemas');
|
||||
const { getTenantId, getUserId, logger } = require('@librechat/data-schemas');
|
||||
const { isEnabled, maybeRefreshCloudFrontAuthCookiesMiddleware } = require('@librechat/api');
|
||||
const passport = require('passport');
|
||||
|
||||
|
|
@ -127,6 +278,10 @@ describe('requireJwtAuth tenant context chaining', () => {
|
|||
mockRegisteredStrategies = new Set(['jwt']);
|
||||
isEnabled.mockReturnValue(false);
|
||||
maybeRefreshCloudFrontAuthCookiesMiddleware.mockClear();
|
||||
logger.debug.mockClear();
|
||||
logger.info.mockClear();
|
||||
logger.warn.mockClear();
|
||||
logger.error.mockClear();
|
||||
passport.authenticate.mockClear();
|
||||
passport._strategy.mockClear();
|
||||
if (originalJwtSecret === undefined) {
|
||||
|
|
@ -204,6 +359,207 @@ describe('requireJwtAuth tenant context chaining', () => {
|
|||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(getTenantId()).toBeUndefined();
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
expect.stringContaining('[requireJwtAuth] Authentication failed after all strategies'),
|
||||
expect.objectContaining({
|
||||
primary_strategy: 'jwt',
|
||||
fallback_attempted: false,
|
||||
fallback_succeeded: false,
|
||||
attempted_strategies: ['jwt'],
|
||||
final_strategy: 'jwt',
|
||||
reason: 'Unauthorized',
|
||||
status: 401,
|
||||
}),
|
||||
);
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('logs OpenID JWT expiry when JWT fallback succeeds', () => {
|
||||
isEnabled.mockReturnValue(true);
|
||||
mockRegisteredStrategies.add('openidJwt');
|
||||
const req = mockReq(undefined, {
|
||||
requestId: 'req-expired-success',
|
||||
method: 'GET',
|
||||
path: '/api/messages',
|
||||
headers: {
|
||||
cookie: `token_provider=openid; openid_user_id=${signedOpenIdUserCookie('user-jwt')}`,
|
||||
},
|
||||
_mockStrategies: {
|
||||
openidJwt: {
|
||||
user: false,
|
||||
info: { message: 'jwt expired', name: 'TokenExpiredError' },
|
||||
status: 401,
|
||||
},
|
||||
jwt: { user: { id: 'user-jwt', tenantId: 'tenant-jwt', role: 'user' } },
|
||||
},
|
||||
});
|
||||
const res = mockRes();
|
||||
const next = jest.fn();
|
||||
|
||||
requireJwtAuth(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(req.authStrategy).toBe('jwt');
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
expect.stringContaining('[requireJwtAuth] OpenID JWT auth failed; trying fallback'),
|
||||
expect.objectContaining({
|
||||
request_id: 'req-expired-success',
|
||||
method: 'GET',
|
||||
path: '/api/messages',
|
||||
token_provider: 'openid',
|
||||
openid_reuse_enabled: true,
|
||||
openid_jwt_available: true,
|
||||
has_openid_reuse_user_id: true,
|
||||
primary_strategy: 'openidJwt',
|
||||
fallback_strategy: 'jwt',
|
||||
fallback_attempted: true,
|
||||
reason: 'jwt expired',
|
||||
error_name: 'TokenExpiredError',
|
||||
status: 401,
|
||||
}),
|
||||
);
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
expect.stringContaining('[requireJwtAuth] JWT fallback succeeded after OpenID JWT failure'),
|
||||
expect.objectContaining({
|
||||
request_id: 'req-expired-success',
|
||||
auth_strategy: 'jwt',
|
||||
primary_strategy: 'openidJwt',
|
||||
fallback_strategy: 'jwt',
|
||||
fallback_attempted: true,
|
||||
fallback_succeeded: true,
|
||||
primary_failure_reason: 'jwt expired',
|
||||
reason: 'jwt expired',
|
||||
error_name: 'TokenExpiredError',
|
||||
}),
|
||||
);
|
||||
expect(logger.debug.mock.calls[0][0]).toContain('"reason":"jwt expired"');
|
||||
expect(logger.debug.mock.calls[0][0]).toContain('"fallback_attempted":true');
|
||||
expect(logger.debug.mock.calls[1][0]).toContain('"fallback_succeeded":true');
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not let malformed Passport info break JWT fallback logging', () => {
|
||||
isEnabled.mockReturnValue(true);
|
||||
mockRegisteredStrategies.add('openidJwt');
|
||||
const info = {};
|
||||
Object.defineProperties(info, {
|
||||
message: {
|
||||
get() {
|
||||
throw new TypeError('message getter failed');
|
||||
},
|
||||
},
|
||||
name: {
|
||||
get() {
|
||||
throw new TypeError('name getter failed');
|
||||
},
|
||||
},
|
||||
});
|
||||
const req = mockReq(undefined, {
|
||||
requestId: 'req-malformed-info',
|
||||
method: 'GET',
|
||||
path: '/api/messages',
|
||||
headers: {
|
||||
cookie: `token_provider=openid; openid_user_id=${signedOpenIdUserCookie('user-jwt')}`,
|
||||
},
|
||||
_mockStrategies: {
|
||||
openidJwt: {
|
||||
user: false,
|
||||
info,
|
||||
status: 401,
|
||||
},
|
||||
jwt: { user: { id: 'user-jwt', tenantId: 'tenant-jwt', role: 'user' } },
|
||||
},
|
||||
});
|
||||
const res = mockRes();
|
||||
const next = jest.fn();
|
||||
|
||||
expect(() => requireJwtAuth(req, res, next)).not.toThrow();
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(req.authStrategy).toBe('jwt');
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
expect.stringContaining('[requireJwtAuth] OpenID JWT auth failed; trying fallback'),
|
||||
expect.objectContaining({
|
||||
request_id: 'req-malformed-info',
|
||||
fallback_attempted: true,
|
||||
reason: 'Unauthorized',
|
||||
status: 401,
|
||||
}),
|
||||
);
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
expect.stringContaining('[requireJwtAuth] JWT fallback succeeded after OpenID JWT failure'),
|
||||
expect.objectContaining({
|
||||
request_id: 'req-malformed-info',
|
||||
fallback_succeeded: true,
|
||||
primary_failure_reason: 'Unauthorized',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('logs OpenID JWT expiry when JWT fallback fails', () => {
|
||||
isEnabled.mockReturnValue(true);
|
||||
mockRegisteredStrategies.add('openidJwt');
|
||||
const req = mockReq(undefined, {
|
||||
id: 'req-expired-fail',
|
||||
method: 'POST',
|
||||
originalUrl: '/api/ask?access_token=hidden',
|
||||
headers: {
|
||||
cookie: `token_provider=openid; openid_user_id=${signedOpenIdUserCookie('user-jwt')}`,
|
||||
},
|
||||
_mockStrategies: {
|
||||
openidJwt: {
|
||||
user: false,
|
||||
info: { message: 'jwt expired', name: 'TokenExpiredError' },
|
||||
status: 401,
|
||||
},
|
||||
jwt: {
|
||||
user: false,
|
||||
info: { message: 'invalid signature', name: 'JsonWebTokenError' },
|
||||
status: 401,
|
||||
},
|
||||
},
|
||||
});
|
||||
const res = mockRes();
|
||||
const next = jest.fn();
|
||||
|
||||
requireJwtAuth(req, res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
expect.stringContaining('[requireJwtAuth] OpenID JWT auth failed; trying fallback'),
|
||||
expect.objectContaining({
|
||||
request_id: 'req-expired-fail',
|
||||
method: 'POST',
|
||||
path: '/api/ask',
|
||||
fallback_attempted: true,
|
||||
reason: 'jwt expired',
|
||||
error_name: 'TokenExpiredError',
|
||||
status: 401,
|
||||
}),
|
||||
);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('[requireJwtAuth] Authentication failed after all strategies'),
|
||||
expect.objectContaining({
|
||||
request_id: 'req-expired-fail',
|
||||
method: 'POST',
|
||||
path: '/api/ask',
|
||||
token_provider: 'openid',
|
||||
attempted_strategies: ['openidJwt', 'jwt'],
|
||||
final_strategy: 'jwt',
|
||||
primary_strategy: 'openidJwt',
|
||||
fallback_strategy: 'jwt',
|
||||
fallback_attempted: true,
|
||||
fallback_succeeded: false,
|
||||
reason: 'invalid signature',
|
||||
error_name: 'JsonWebTokenError',
|
||||
status: 401,
|
||||
}),
|
||||
);
|
||||
expect(logger.warn.mock.calls[0][0]).toContain('"reason":"invalid signature"');
|
||||
expect(logger.warn.mock.calls[0][0]).toContain('"path":"/api/ask"');
|
||||
});
|
||||
|
||||
it('does not fall back to OpenID JWT for bearer-only reuse requests', () => {
|
||||
|
|
@ -263,6 +619,98 @@ describe('requireJwtAuth tenant context chaining', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('logs OpenID user-id mismatch when JWT fallback succeeds', () => {
|
||||
isEnabled.mockReturnValue(true);
|
||||
mockRegisteredStrategies.add('openidJwt');
|
||||
const req = mockReq(undefined, {
|
||||
requestId: 'req-mismatch-success',
|
||||
method: 'GET',
|
||||
path: '/api/auth/me',
|
||||
headers: {
|
||||
cookie: `token_provider=openid; openid_user_id=${signedOpenIdUserCookie('user-a')}`,
|
||||
},
|
||||
_mockStrategies: {
|
||||
openidJwt: { user: { id: 'user-b', tenantId: 'tenant-openid', role: 'user' } },
|
||||
jwt: { user: { id: 'user-a', tenantId: 'tenant-jwt', role: 'user' } },
|
||||
},
|
||||
});
|
||||
const res = mockRes();
|
||||
const next = jest.fn();
|
||||
|
||||
requireJwtAuth(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(req.authStrategy).toBe('jwt');
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
expect.stringContaining('[requireJwtAuth] OpenID JWT auth failed; trying fallback'),
|
||||
expect.objectContaining({
|
||||
request_id: 'req-mismatch-success',
|
||||
primary_strategy: 'openidJwt',
|
||||
fallback_strategy: 'jwt',
|
||||
fallback_attempted: true,
|
||||
reason: 'openid user-id mismatch',
|
||||
status: 401,
|
||||
}),
|
||||
);
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
expect.stringContaining('[requireJwtAuth] JWT fallback succeeded after OpenID JWT failure'),
|
||||
expect.objectContaining({
|
||||
request_id: 'req-mismatch-success',
|
||||
auth_strategy: 'jwt',
|
||||
fallback_attempted: true,
|
||||
fallback_succeeded: true,
|
||||
primary_failure_reason: 'openid user-id mismatch',
|
||||
reason: 'openid user-id mismatch',
|
||||
}),
|
||||
);
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('logs OpenID user-id mismatch when JWT fallback fails', () => {
|
||||
isEnabled.mockReturnValue(true);
|
||||
mockRegisteredStrategies.add('openidJwt');
|
||||
const req = mockReq(undefined, {
|
||||
requestId: 'req-mismatch-fail',
|
||||
method: 'GET',
|
||||
path: '/api/auth/me',
|
||||
headers: {
|
||||
cookie: `token_provider=openid; openid_user_id=${signedOpenIdUserCookie('user-a')}`,
|
||||
},
|
||||
_mockStrategies: {
|
||||
openidJwt: { user: { id: 'user-b', tenantId: 'tenant-openid', role: 'user' } },
|
||||
jwt: { user: false, info: { message: 'Unauthorized' }, status: 401 },
|
||||
},
|
||||
});
|
||||
const res = mockRes();
|
||||
const next = jest.fn();
|
||||
|
||||
requireJwtAuth(req, res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
expect.stringContaining('[requireJwtAuth] OpenID JWT auth failed; trying fallback'),
|
||||
expect.objectContaining({
|
||||
request_id: 'req-mismatch-fail',
|
||||
fallback_attempted: true,
|
||||
reason: 'openid user-id mismatch',
|
||||
status: 401,
|
||||
}),
|
||||
);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('[requireJwtAuth] Authentication failed after all strategies'),
|
||||
expect.objectContaining({
|
||||
request_id: 'req-mismatch-fail',
|
||||
attempted_strategies: ['openidJwt', 'jwt'],
|
||||
final_strategy: 'jwt',
|
||||
fallback_attempted: true,
|
||||
fallback_succeeded: false,
|
||||
reason: 'Unauthorized',
|
||||
status: 401,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not authenticate OpenID JWT when the reuse cookie belongs to another user', () => {
|
||||
isEnabled.mockReturnValue(true);
|
||||
mockRegisteredStrategies.add('openidJwt');
|
||||
|
|
|
|||
|
|
@ -1,9 +1,14 @@
|
|||
const cookies = require('cookie');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const passport = require('passport');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const {
|
||||
isEnabled,
|
||||
tenantContextMiddleware,
|
||||
getAuthFailureReason,
|
||||
getAuthFailureErrorName,
|
||||
buildSafeAuthLogContext,
|
||||
formatAuthLogMessage,
|
||||
maybeRefreshCloudFrontAuthCookiesMiddleware,
|
||||
} = require('@librechat/api');
|
||||
|
||||
|
|
@ -48,6 +53,66 @@ const requireJwtAuth = (req, res, next) => {
|
|||
const useOpenIdJwt =
|
||||
tokenProvider === 'openid' && openidJwtAvailable && openIdReuseUserId != null;
|
||||
const strategies = useOpenIdJwt ? ['openidJwt', 'jwt'] : ['jwt'];
|
||||
const authLogState = {
|
||||
tokenProvider,
|
||||
openidReuseEnabled,
|
||||
openidJwtAvailable,
|
||||
hasOpenIdReuseUserId: openIdReuseUserId != null,
|
||||
};
|
||||
let primaryFailureReason;
|
||||
let primaryFailureErrorName;
|
||||
let fallbackAttempted = false;
|
||||
|
||||
const logOpenIdFallbackAttempt = ({ fallbackStrategy, reason, errorName, status }) => {
|
||||
primaryFailureReason = reason;
|
||||
primaryFailureErrorName = errorName;
|
||||
fallbackAttempted = true;
|
||||
const message = '[requireJwtAuth] OpenID JWT auth failed; trying fallback';
|
||||
const context = buildSafeAuthLogContext(req, authLogState, {
|
||||
primary_strategy: 'openidJwt',
|
||||
fallback_strategy: fallbackStrategy,
|
||||
fallback_attempted: true,
|
||||
reason,
|
||||
error_name: errorName,
|
||||
status,
|
||||
});
|
||||
logger.debug(formatAuthLogMessage(message, context), context);
|
||||
};
|
||||
|
||||
const logAuthenticationFailure = ({ strategy, info, status, err }) => {
|
||||
const message = '[requireJwtAuth] Authentication failed after all strategies';
|
||||
const context = buildSafeAuthLogContext(req, authLogState, {
|
||||
primary_strategy: strategies[0],
|
||||
fallback_strategy: strategies[1],
|
||||
fallback_attempted: fallbackAttempted,
|
||||
fallback_succeeded: false,
|
||||
attempted_strategies: strategies,
|
||||
final_strategy: strategy,
|
||||
reason: getAuthFailureReason(err, info),
|
||||
error_name: getAuthFailureErrorName(err, info),
|
||||
status: status || 401,
|
||||
});
|
||||
const log = fallbackAttempted ? logger.warn : logger.debug;
|
||||
log.call(logger, formatAuthLogMessage(message, context), context);
|
||||
};
|
||||
|
||||
const logFallbackSuccess = (strategy) => {
|
||||
if (!fallbackAttempted || strategy !== 'jwt') {
|
||||
return;
|
||||
}
|
||||
const message = '[requireJwtAuth] JWT fallback succeeded after OpenID JWT failure';
|
||||
const context = buildSafeAuthLogContext(req, authLogState, {
|
||||
auth_strategy: 'jwt',
|
||||
primary_strategy: 'openidJwt',
|
||||
fallback_strategy: 'jwt',
|
||||
fallback_attempted: true,
|
||||
fallback_succeeded: true,
|
||||
primary_failure_reason: primaryFailureReason,
|
||||
reason: primaryFailureReason,
|
||||
error_name: primaryFailureErrorName,
|
||||
});
|
||||
logger.debug(formatAuthLogMessage(message, context), context);
|
||||
};
|
||||
|
||||
const authenticateWithStrategy = (index) => {
|
||||
const strategy = strategies[index];
|
||||
|
|
@ -57,20 +122,34 @@ const requireJwtAuth = (req, res, next) => {
|
|||
}
|
||||
if (!user) {
|
||||
if (index + 1 < strategies.length) {
|
||||
logOpenIdFallbackAttempt({
|
||||
fallbackStrategy: strategies[index + 1],
|
||||
reason: getAuthFailureReason(err, info),
|
||||
errorName: getAuthFailureErrorName(err, info),
|
||||
status: status || 401,
|
||||
});
|
||||
return authenticateWithStrategy(index + 1);
|
||||
}
|
||||
logAuthenticationFailure({ strategy, info, status, err });
|
||||
return res.status(status || 401).json({
|
||||
message: info?.message || 'Unauthorized',
|
||||
});
|
||||
}
|
||||
if (strategy === 'openidJwt' && getAuthenticatedUserId(user) !== openIdReuseUserId) {
|
||||
if (index + 1 < strategies.length) {
|
||||
logOpenIdFallbackAttempt({
|
||||
fallbackStrategy: strategies[index + 1],
|
||||
reason: 'openid user-id mismatch',
|
||||
status: 401,
|
||||
});
|
||||
return authenticateWithStrategy(index + 1);
|
||||
}
|
||||
logAuthenticationFailure({ strategy, info, status: 401, err });
|
||||
return res.status(401).json({ message: 'Unauthorized' });
|
||||
}
|
||||
req.user = user;
|
||||
req.authStrategy = strategy;
|
||||
logFallbackSuccess(strategy);
|
||||
tenantContextMiddleware(req, res, (tenantErr) => {
|
||||
if (tenantErr) {
|
||||
return next(tenantErr);
|
||||
|
|
|
|||
206
packages/api/src/middleware/auth.spec.ts
Normal file
206
packages/api/src/middleware/auth.spec.ts
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
import {
|
||||
buildSafeAuthLogContext,
|
||||
formatAuthLogMessage,
|
||||
getAuthFailureErrorName,
|
||||
getAuthFailureReason,
|
||||
} from './auth';
|
||||
import type { AuthLogRequest, AuthLogState } from './auth';
|
||||
|
||||
function createRequest(overrides: Partial<AuthLogRequest> = {}): AuthLogRequest {
|
||||
return {
|
||||
headers: {},
|
||||
method: 'GET',
|
||||
path: '/api/messages',
|
||||
originalUrl: '/api/messages',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createAuthState(overrides: Partial<AuthLogState> = {}): AuthLogState {
|
||||
return {
|
||||
tokenProvider: 'openid',
|
||||
openidReuseEnabled: true,
|
||||
openidJwtAvailable: true,
|
||||
hasOpenIdReuseUserId: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('auth middleware logging helpers', () => {
|
||||
it('builds safe auth log context without raw query strings or user identifiers', () => {
|
||||
const log = buildSafeAuthLogContext(
|
||||
createRequest({
|
||||
id: 'request-id',
|
||||
path: undefined,
|
||||
originalUrl: '/api/ask?access_token=secret-token',
|
||||
}),
|
||||
createAuthState(),
|
||||
{
|
||||
attempted_strategies: ['openidJwt', 'jwt'],
|
||||
fallback_attempted: true,
|
||||
fallback_succeeded: false,
|
||||
reason: 'jwt expired',
|
||||
error_name: 'TokenExpiredError',
|
||||
status: 401,
|
||||
},
|
||||
);
|
||||
|
||||
expect(log).toEqual({
|
||||
request_id: 'request-id',
|
||||
method: 'GET',
|
||||
path: '/api/ask',
|
||||
token_provider: 'openid',
|
||||
openid_reuse_enabled: true,
|
||||
openid_jwt_available: true,
|
||||
has_openid_reuse_user_id: true,
|
||||
attempted_strategies: ['openidJwt', 'jwt'],
|
||||
fallback_attempted: true,
|
||||
fallback_succeeded: false,
|
||||
reason: 'jwt expired',
|
||||
error_name: 'TokenExpiredError',
|
||||
status: 401,
|
||||
});
|
||||
expect(JSON.stringify(log)).not.toContain('secret-token');
|
||||
});
|
||||
|
||||
it('uses request headers when request ids are not directly set', () => {
|
||||
const log = buildSafeAuthLogContext(
|
||||
createRequest({
|
||||
headers: {
|
||||
'x-request-id': ['header-request-id'],
|
||||
},
|
||||
}),
|
||||
createAuthState({
|
||||
tokenProvider: null,
|
||||
openidReuseEnabled: false,
|
||||
openidJwtAvailable: false,
|
||||
hasOpenIdReuseUserId: false,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(log).toEqual({
|
||||
request_id: 'header-request-id',
|
||||
method: 'GET',
|
||||
path: '/api/messages',
|
||||
openid_reuse_enabled: false,
|
||||
openid_jwt_available: false,
|
||||
has_openid_reuse_user_id: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('buckets unknown token providers to keep auth logs low-cardinality', () => {
|
||||
const log = buildSafeAuthLogContext(
|
||||
createRequest(),
|
||||
createAuthState({
|
||||
tokenProvider: 'attacker-controlled-provider',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(log.token_provider).toBe('other');
|
||||
});
|
||||
|
||||
it('prefers route buckets over concrete dynamic request paths', () => {
|
||||
const log = buildSafeAuthLogContext(
|
||||
createRequest({
|
||||
baseUrl: '/api/messages',
|
||||
path: '/conversation-123/message-456',
|
||||
originalUrl: '/api/messages/conversation-123/message-456?access_token=secret-token',
|
||||
}),
|
||||
createAuthState(),
|
||||
);
|
||||
|
||||
expect(log.path).toBe('/api/messages');
|
||||
expect(JSON.stringify(log)).not.toContain('conversation-123');
|
||||
expect(JSON.stringify(log)).not.toContain('message-456');
|
||||
expect(JSON.stringify(log)).not.toContain('secret-token');
|
||||
});
|
||||
|
||||
it('logs route templates when Express exposes them', () => {
|
||||
const log = buildSafeAuthLogContext(
|
||||
createRequest({
|
||||
baseUrl: '/api/share',
|
||||
path: '/link/conversation-123',
|
||||
route: { path: '/link/:conversationId' },
|
||||
}),
|
||||
createAuthState(),
|
||||
);
|
||||
|
||||
expect(log.path).toBe('/api/share/link/:conversationId');
|
||||
});
|
||||
|
||||
it('drops unsupported extra values and keeps safe arrays primitive', () => {
|
||||
const log = buildSafeAuthLogContext(createRequest({ id: 'request-id' }), createAuthState(), {
|
||||
attempted_strategies: ['openidJwt', '', { strategy: 'jwt' }, 'jwt'],
|
||||
fallback_attempted: true,
|
||||
path: { unsafe: true },
|
||||
request_id: { unsafe: true },
|
||||
status: Number.NaN,
|
||||
unsafe_object: { token: 'secret-token' },
|
||||
reason: ' jwt expired ',
|
||||
});
|
||||
|
||||
expect(log).toEqual({
|
||||
request_id: 'request-id',
|
||||
method: 'GET',
|
||||
path: '/api/messages',
|
||||
token_provider: 'openid',
|
||||
openid_reuse_enabled: true,
|
||||
openid_jwt_available: true,
|
||||
has_openid_reuse_user_id: true,
|
||||
attempted_strategies: ['openidJwt', 'jwt'],
|
||||
fallback_attempted: true,
|
||||
reason: 'jwt expired',
|
||||
});
|
||||
expect(JSON.stringify(log)).not.toContain('secret-token');
|
||||
});
|
||||
|
||||
it('formats auth log messages with serialized safe context for stdout collectors', () => {
|
||||
const log = buildSafeAuthLogContext(createRequest({ id: 'request-id' }), createAuthState(), {
|
||||
fallback_attempted: true,
|
||||
reason: 'jwt expired',
|
||||
error_name: 'TokenExpiredError',
|
||||
status: 401,
|
||||
});
|
||||
|
||||
expect(
|
||||
formatAuthLogMessage('[requireJwtAuth] OpenID JWT auth failed; trying fallback', log),
|
||||
).toBe(
|
||||
'[requireJwtAuth] OpenID JWT auth failed; trying fallback {"fallback_attempted":true,"reason":"jwt expired","error_name":"TokenExpiredError","status":401,"request_id":"request-id","method":"GET","path":"/api/messages","token_provider":"openid","openid_reuse_enabled":true,"openid_jwt_available":true,"has_openid_reuse_user_id":true}',
|
||||
);
|
||||
});
|
||||
|
||||
it('prefers Passport info fields for auth failure reason and error name', () => {
|
||||
const err = Object.assign(new Error('outer failure'), { name: 'OuterError' });
|
||||
const info = { message: 'jwt expired', name: 'TokenExpiredError' };
|
||||
|
||||
expect(getAuthFailureReason(err, info)).toBe('jwt expired');
|
||||
expect(getAuthFailureErrorName(err, info)).toBe('TokenExpiredError');
|
||||
});
|
||||
|
||||
it('falls back to Error fields when Passport info is absent', () => {
|
||||
const err = Object.assign(new Error('invalid signature'), { name: 'JsonWebTokenError' });
|
||||
|
||||
expect(getAuthFailureReason(err, undefined)).toBe('invalid signature');
|
||||
expect(getAuthFailureErrorName(err, undefined)).toBe('JsonWebTokenError');
|
||||
});
|
||||
|
||||
it('does not throw when Passport failure objects expose throwing getters', () => {
|
||||
const err = Object.assign(new Error('invalid signature'), { name: 'JsonWebTokenError' });
|
||||
const info = {};
|
||||
Object.defineProperties(info, {
|
||||
message: {
|
||||
get() {
|
||||
throw new TypeError('message getter failed');
|
||||
},
|
||||
},
|
||||
name: {
|
||||
get() {
|
||||
throw new TypeError('name getter failed');
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(getAuthFailureReason(err, info)).toBe('invalid signature');
|
||||
expect(getAuthFailureErrorName(err, info)).toBe('JsonWebTokenError');
|
||||
});
|
||||
});
|
||||
228
packages/api/src/middleware/auth.ts
Normal file
228
packages/api/src/middleware/auth.ts
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
type AuthFailureLike = {
|
||||
message?: unknown;
|
||||
name?: unknown;
|
||||
};
|
||||
|
||||
type AuthLogValue = string | number | boolean | readonly string[];
|
||||
type AuthLogHeaderValue = string | string[] | undefined;
|
||||
type AuthRoutePath = string | RegExp | readonly (string | RegExp)[];
|
||||
|
||||
export type AuthLogRequest = {
|
||||
headers?: Record<string, AuthLogHeaderValue>;
|
||||
method?: string;
|
||||
path?: string;
|
||||
originalUrl?: string;
|
||||
url?: string;
|
||||
baseUrl?: string;
|
||||
route?: {
|
||||
path?: AuthRoutePath;
|
||||
};
|
||||
id?: string;
|
||||
requestId?: string;
|
||||
};
|
||||
|
||||
export type AuthLogState = {
|
||||
tokenProvider?: string | null;
|
||||
openidReuseEnabled: boolean;
|
||||
openidJwtAvailable: boolean;
|
||||
hasOpenIdReuseUserId: boolean;
|
||||
};
|
||||
|
||||
export type AuthLogContext = Record<string, AuthLogValue>;
|
||||
|
||||
function normalizeAuthLogValue(value: unknown): string | undefined {
|
||||
if (value == null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
for (const entry of value) {
|
||||
const normalized = normalizeAuthLogValue(entry);
|
||||
if (normalized) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
return trimmed || undefined;
|
||||
}
|
||||
|
||||
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeAuthLogContextValue(value: unknown): AuthLogValue | undefined {
|
||||
if (value == null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const values = value
|
||||
.map((entry) => normalizeAuthLogValue(entry))
|
||||
.filter((entry): entry is string => entry !== undefined);
|
||||
return values.length > 0 ? values : undefined;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return normalizeAuthLogValue(value);
|
||||
}
|
||||
|
||||
if (typeof value === 'number') {
|
||||
return Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getRequestId(req: AuthLogRequest): string | undefined {
|
||||
return (
|
||||
normalizeAuthLogValue(req.requestId) ??
|
||||
normalizeAuthLogValue(req.id) ??
|
||||
normalizeAuthLogValue(req.headers?.['x-request-id']) ??
|
||||
normalizeAuthLogValue(req.headers?.['x-correlation-id'])
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeRoutePath(path: AuthRoutePath | undefined): string | undefined {
|
||||
if (typeof path === 'string') {
|
||||
return normalizeAuthLogValue(path);
|
||||
}
|
||||
|
||||
if (Array.isArray(path)) {
|
||||
for (const entry of path) {
|
||||
const normalized = normalizeRoutePath(entry);
|
||||
if (normalized) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function joinRoutePath(baseUrl: string | undefined, routePath: string): string {
|
||||
const normalizedRoute = routePath === '/' ? '' : routePath;
|
||||
if (!baseUrl) {
|
||||
return normalizedRoute || '/';
|
||||
}
|
||||
if (!normalizedRoute) {
|
||||
return baseUrl;
|
||||
}
|
||||
return `${baseUrl.replace(/\/$/, '')}/${normalizedRoute.replace(/^\//, '')}`;
|
||||
}
|
||||
|
||||
function bucketConcretePath(path: string | undefined): string | undefined {
|
||||
const queryless = path?.split('?')[0];
|
||||
if (!queryless) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const segments = queryless.split('/').filter(Boolean);
|
||||
if (segments.length === 0) {
|
||||
return '/';
|
||||
}
|
||||
if (segments[0] === 'api' && segments[1]) {
|
||||
return `/${segments.slice(0, 2).join('/')}`;
|
||||
}
|
||||
return `/${segments[0]}`;
|
||||
}
|
||||
|
||||
function getRequestPath(req: AuthLogRequest): string | undefined {
|
||||
const baseUrl = normalizeAuthLogValue(req.baseUrl);
|
||||
const routePath = normalizeRoutePath(req.route?.path);
|
||||
if (routePath) {
|
||||
return joinRoutePath(baseUrl, routePath);
|
||||
}
|
||||
if (baseUrl) {
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
const path = normalizeAuthLogValue(req.path) ?? normalizeAuthLogValue(req.originalUrl ?? req.url);
|
||||
return bucketConcretePath(path);
|
||||
}
|
||||
|
||||
function getAuthFailureField(source: unknown, field: keyof AuthFailureLike): unknown {
|
||||
if (!source) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof source === 'string') {
|
||||
return field === 'message' ? source : undefined;
|
||||
}
|
||||
if (typeof source === 'object') {
|
||||
try {
|
||||
return (source as AuthFailureLike)[field];
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function compactAuthLogContext(log: Record<string, unknown>): AuthLogContext {
|
||||
const compacted: Partial<AuthLogContext> = {};
|
||||
for (const key of Object.keys(log)) {
|
||||
const value = normalizeAuthLogContextValue(log[key]);
|
||||
if (value !== undefined) {
|
||||
Object.assign(compacted, { [key]: value });
|
||||
}
|
||||
}
|
||||
return compacted as AuthLogContext;
|
||||
}
|
||||
|
||||
export function getAuthFailureReason(
|
||||
err: unknown,
|
||||
info: unknown,
|
||||
fallback = 'Unauthorized',
|
||||
): string {
|
||||
return (
|
||||
normalizeAuthLogValue(getAuthFailureField(info, 'message')) ??
|
||||
normalizeAuthLogValue(getAuthFailureField(err, 'message')) ??
|
||||
fallback
|
||||
);
|
||||
}
|
||||
|
||||
export function getAuthFailureErrorName(err: unknown, info: unknown): string | undefined {
|
||||
return (
|
||||
normalizeAuthLogValue(getAuthFailureField(info, 'name')) ??
|
||||
normalizeAuthLogValue(getAuthFailureField(err, 'name'))
|
||||
);
|
||||
}
|
||||
|
||||
function getSafeTokenProvider(tokenProvider: unknown): string | undefined {
|
||||
const normalized = normalizeAuthLogValue(tokenProvider);
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
return normalized === 'openid' || normalized === 'librechat' ? normalized : 'other';
|
||||
}
|
||||
|
||||
export function buildSafeAuthLogContext(
|
||||
req: AuthLogRequest,
|
||||
authState: AuthLogState,
|
||||
extra: Record<string, unknown> = {},
|
||||
): AuthLogContext {
|
||||
return compactAuthLogContext({
|
||||
...extra,
|
||||
request_id: getRequestId(req),
|
||||
method: normalizeAuthLogValue(req.method),
|
||||
path: getRequestPath(req),
|
||||
token_provider: getSafeTokenProvider(authState.tokenProvider),
|
||||
openid_reuse_enabled: authState.openidReuseEnabled,
|
||||
openid_jwt_available: authState.openidJwtAvailable,
|
||||
has_openid_reuse_user_id: authState.hasOpenIdReuseUserId,
|
||||
});
|
||||
}
|
||||
|
||||
export function formatAuthLogMessage(message: string, context: AuthLogContext): string {
|
||||
return `${message} ${JSON.stringify(context)}`;
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ export * from './notFound';
|
|||
export * from './balance';
|
||||
export * from './json';
|
||||
export * from './capabilities';
|
||||
export * from './auth';
|
||||
export {
|
||||
tenantContextMiddleware,
|
||||
restoreTenantContextFromReq,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue