mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
When OPENID_REUSE_TOKENS is enabled and both the openidJwt strategy and the HS256 jwt fallback fail, the final 'Authentication failed after all strategies' warn log reported only the fallback's reason. For an RS256 provider (Keycloak, Auth0, Okta) that surfaces as 'invalid algorithm', which is the HS256 fallback rejecting the provider token, not the real reason openidJwt did not authenticate, and it was previously only visible at debug level. Include the captured primary (openidJwt) failure reason and error name in the final warn log so reused-token failures are diagnosable without enabling debug and are not misattributed to the fallback. Refs #14311
247 lines
8.3 KiB
JavaScript
247 lines
8.3 KiB
JavaScript
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,
|
|
recordRumProxyRequest,
|
|
} = require('@librechat/api');
|
|
|
|
const hasPassportStrategy = (strategy) =>
|
|
typeof passport._strategy === 'function' && passport._strategy(strategy) != null;
|
|
|
|
const getValidOpenIdReuseUserId = (parsedCookies) => {
|
|
const openidUserId = parsedCookies.openid_user_id;
|
|
if (!openidUserId || !process.env.JWT_REFRESH_SECRET) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const payload = jwt.verify(openidUserId, process.env.JWT_REFRESH_SECRET);
|
|
return typeof payload === 'object' && payload != null && typeof payload.id === 'string'
|
|
? payload.id
|
|
: null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const getAuthenticatedUserId = (user) => user?.id?.toString?.() ?? user?._id?.toString?.();
|
|
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.
|
|
*
|
|
* After successful authentication (req.user populated), automatically chains into
|
|
* `tenantContextMiddleware` to propagate request context into AsyncLocalStorage
|
|
* for downstream Mongoose tenant isolation and structured logging.
|
|
*/
|
|
const requireJwtAuth = (req, res, next) => {
|
|
const { tokenProvider, openidReuseEnabled, openidJwtAvailable, openIdReuseUserId, strategies } =
|
|
getAuthStrategies(req);
|
|
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,
|
|
// Surface the primary (openidJwt) failure alongside the final strategy's reason so a
|
|
// reused-token failure is not misattributed to the HS256 `jwt` fallback's "invalid
|
|
// algorithm" error, which is the fallback rejecting an RS256 provider token, not the
|
|
// real reason openidJwt did not authenticate.
|
|
...(fallbackAttempted && {
|
|
primary_failure_reason: primaryFailureReason,
|
|
primary_failure_error_name: primaryFailureErrorName,
|
|
}),
|
|
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];
|
|
passport.authenticate(strategy, { session: false }, (err, user, info, status) => {
|
|
if (err) {
|
|
return next(err);
|
|
}
|
|
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);
|
|
}
|
|
refreshCloudFrontCookies(req, res, next);
|
|
});
|
|
})(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;
|