mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-03-21 23:26:34 +01:00
* fix: automatic logout_hint fallback for long OpenID tokens
Implements OIDC RP-Initiated Logout cascading strategy to prevent errors when id_token_hint makes logout URL too long.
Automatically detects URLs exceeding configurable length and falls back to logout_hint only when URL is too long, preserving previous behavior when token is missing. Adds OPENID_MAX_LOGOUT_URL_LENGTH environment variable. Comprehensive test coverage with 20 tests. Works with any OpenID provider.
* fix: address review findings for OIDC logout URL length fallback
- Replace two-boolean tri-state (useIdTokenHint/urlTooLong) with a single
string discriminant ('use_token'|'too_long'|'no_token') for clarity
- Fix misleading warning: differentiate 'url too long + no client_id' from
'no token + no client_id' so operators get actionable advice
- Strict env var parsing: reject partial numeric strings like '500abc' that
Number.parseInt silently accepted; use regex + Number() instead
- Pre-compute projected URL length from base URL + token length (JWT chars
are URL-safe), eliminating the set-then-delete mutation pattern
- Extract parseMaxLogoutUrlLength helper for validation and early return
- Add tests: invalid env values, url-too-long + missing OPENID_CLIENT_ID,
boundary condition (exact max vs max+1), cookie-sourced long token
- Remove redundant try/finally in 'respects custom limit' test
- Use empty value in .env.example to signal optional config (default: 2000)
---------
Co-authored-by: Airam Hernández Hernández <airam.hernandez@intelequia.com>
Co-authored-by: Danny Avila <danny@librechat.ai>
140 lines
5.6 KiB
JavaScript
140 lines
5.6 KiB
JavaScript
const cookies = require('cookie');
|
|
const { isEnabled } = require('@librechat/api');
|
|
const { logger } = require('@librechat/data-schemas');
|
|
const { logoutUser } = require('~/server/services/AuthService');
|
|
const { getOpenIdConfig } = require('~/strategies');
|
|
|
|
/** Parses and validates OPENID_MAX_LOGOUT_URL_LENGTH, returning defaultValue on invalid input */
|
|
function parseMaxLogoutUrlLength(defaultValue = 2000) {
|
|
const raw = process.env.OPENID_MAX_LOGOUT_URL_LENGTH;
|
|
const trimmed = raw == null ? '' : raw.trim();
|
|
if (trimmed === '') {
|
|
return defaultValue;
|
|
}
|
|
const parsed = /^\d+$/.test(trimmed) ? Number(trimmed) : NaN;
|
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
logger.warn(
|
|
`[logoutController] Invalid OPENID_MAX_LOGOUT_URL_LENGTH value "${raw}", using default ${defaultValue}`,
|
|
);
|
|
return defaultValue;
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
const logoutController = async (req, res) => {
|
|
const parsedCookies = req.headers.cookie ? cookies.parse(req.headers.cookie) : {};
|
|
const isOpenIdUser = req.user?.openidId != null && req.user?.provider === 'openid';
|
|
|
|
let refreshToken;
|
|
let idToken;
|
|
if (isOpenIdUser && req.session?.openidTokens) {
|
|
refreshToken = req.session.openidTokens.refreshToken;
|
|
idToken = req.session.openidTokens.idToken;
|
|
delete req.session.openidTokens;
|
|
}
|
|
refreshToken = refreshToken || parsedCookies.refreshToken;
|
|
idToken = idToken || parsedCookies.openid_id_token;
|
|
|
|
try {
|
|
const logout = await logoutUser(req, refreshToken);
|
|
const { status, message } = logout;
|
|
|
|
res.clearCookie('refreshToken');
|
|
res.clearCookie('openid_access_token');
|
|
res.clearCookie('openid_id_token');
|
|
res.clearCookie('openid_user_id');
|
|
res.clearCookie('token_provider');
|
|
const response = { message };
|
|
if (
|
|
isOpenIdUser &&
|
|
isEnabled(process.env.OPENID_USE_END_SESSION_ENDPOINT) &&
|
|
process.env.OPENID_ISSUER
|
|
) {
|
|
let openIdConfig;
|
|
try {
|
|
openIdConfig = getOpenIdConfig();
|
|
} catch (err) {
|
|
logger.warn('[logoutController] OpenID config not available:', err.message);
|
|
}
|
|
if (openIdConfig) {
|
|
const endSessionEndpoint = openIdConfig.serverMetadata().end_session_endpoint;
|
|
if (endSessionEndpoint) {
|
|
const endSessionUrl = new URL(endSessionEndpoint);
|
|
const postLogoutRedirectUri =
|
|
process.env.OPENID_POST_LOGOUT_REDIRECT_URI || `${process.env.DOMAIN_CLIENT}/login`;
|
|
endSessionUrl.searchParams.set('post_logout_redirect_uri', postLogoutRedirectUri);
|
|
|
|
/**
|
|
* OIDC RP-Initiated Logout cascading strategy:
|
|
* 1. id_token_hint (most secure, identifies exact session)
|
|
* 2. logout_hint + client_id (when URL would exceed safe length)
|
|
* 3. client_id only (when no token available)
|
|
*
|
|
* JWT tokens from spec-compliant OIDC providers use base64url
|
|
* encoding (RFC 7515), whose characters are all URL-safe, so
|
|
* token length equals URL-encoded length for projection.
|
|
* Non-compliant issuers using standard base64 (+/=) will cause
|
|
* underestimation; increase OPENID_MAX_LOGOUT_URL_LENGTH if the
|
|
* fallback does not trigger as expected.
|
|
*/
|
|
const maxLogoutUrlLength = parseMaxLogoutUrlLength();
|
|
let strategy = 'no_token';
|
|
if (idToken) {
|
|
const baseLength = endSessionUrl.toString().length;
|
|
const projectedLength = baseLength + '&id_token_hint='.length + idToken.length;
|
|
if (projectedLength > maxLogoutUrlLength) {
|
|
strategy = 'too_long';
|
|
logger.debug(
|
|
`[logoutController] Logout URL too long (${projectedLength} chars, max ${maxLogoutUrlLength}), ` +
|
|
'switching to logout_hint strategy',
|
|
);
|
|
} else {
|
|
strategy = 'use_token';
|
|
}
|
|
}
|
|
|
|
if (strategy === 'use_token') {
|
|
endSessionUrl.searchParams.set('id_token_hint', idToken);
|
|
} else {
|
|
if (strategy === 'too_long') {
|
|
const logoutHint = req.user?.email || req.user?.username || req.user?.openidId;
|
|
if (logoutHint) {
|
|
endSessionUrl.searchParams.set('logout_hint', logoutHint);
|
|
}
|
|
}
|
|
|
|
if (process.env.OPENID_CLIENT_ID) {
|
|
endSessionUrl.searchParams.set('client_id', process.env.OPENID_CLIENT_ID);
|
|
} else if (strategy === 'too_long') {
|
|
logger.warn(
|
|
'[logoutController] Logout URL exceeds max length and OPENID_CLIENT_ID is not set. ' +
|
|
'The OIDC end-session request may be rejected. ' +
|
|
'Consider setting OPENID_CLIENT_ID or increasing OPENID_MAX_LOGOUT_URL_LENGTH.',
|
|
);
|
|
} else {
|
|
logger.warn(
|
|
'[logoutController] Neither id_token_hint nor OPENID_CLIENT_ID is available. ' +
|
|
'To enable id_token_hint, set OPENID_REUSE_TOKENS=true. ' +
|
|
'The OIDC end-session request may be rejected by the identity provider.',
|
|
);
|
|
}
|
|
}
|
|
|
|
response.redirect = endSessionUrl.toString();
|
|
} else {
|
|
logger.warn(
|
|
'[logoutController] end_session_endpoint not found in OpenID issuer metadata. Please verify that the issuer is correct.',
|
|
);
|
|
}
|
|
}
|
|
}
|
|
return res.status(status).send(response);
|
|
} catch (err) {
|
|
logger.error('[logoutController]', err);
|
|
return res.status(500).json({ message: err.message });
|
|
}
|
|
};
|
|
|
|
module.exports = {
|
|
logoutController,
|
|
};
|