diff --git a/.env.example b/.env.example index 4ab6ded239..b5613fdfca 100644 --- a/.env.example +++ b/.env.example @@ -525,6 +525,8 @@ OPENID_ON_BEHALF_FLOW_FOR_USERINFO_REQUIRED= OPENID_ON_BEHALF_FLOW_USERINFO_SCOPE="user.read" # example for Scope Needed for Microsoft Graph API # Set to true to use the OpenID Connect end session endpoint for logout OPENID_USE_END_SESSION_ENDPOINT= +# URL to redirect to after OpenID logout (defaults to ${DOMAIN_CLIENT}/login) +OPENID_POST_LOGOUT_REDIRECT_URI= #========================# # SharePoint Integration # diff --git a/api/cache/banViolation.js b/api/cache/banViolation.js index 3a2d9791b4..122355edb1 100644 --- a/api/cache/banViolation.js +++ b/api/cache/banViolation.js @@ -47,7 +47,16 @@ const banViolation = async (req, res, errorMessage) => { } await deleteAllUserSessions({ userId: user_id }); + + /** Clear OpenID session tokens if present */ + if (req.session?.openidTokens) { + delete req.session.openidTokens; + } + res.clearCookie('refreshToken'); + res.clearCookie('openid_access_token'); + res.clearCookie('openid_user_id'); + res.clearCookie('token_provider'); const banLogs = getLogStores(ViolationTypes.BAN); const duration = errorMessage.duration || banLogs.opts.ttl; diff --git a/api/server/controllers/AuthController.js b/api/server/controllers/AuthController.js index cb4e1a7eea..22e53dcfc9 100644 --- a/api/server/controllers/AuthController.js +++ b/api/server/controllers/AuthController.js @@ -66,14 +66,17 @@ const resetPasswordController = async (req, res) => { }; const refreshController = async (req, res) => { - const refreshToken = req.headers.cookie ? cookies.parse(req.headers.cookie).refreshToken : null; - const token_provider = req.headers.cookie - ? cookies.parse(req.headers.cookie).token_provider - : null; - if (!refreshToken) { - return res.status(200).send('Refresh token not provided'); - } - if (token_provider === 'openid' && isEnabled(process.env.OPENID_REUSE_TOKENS) === true) { + const parsedCookies = req.headers.cookie ? cookies.parse(req.headers.cookie) : {}; + const token_provider = parsedCookies.token_provider; + + if (token_provider === 'openid' && isEnabled(process.env.OPENID_REUSE_TOKENS)) { + /** For OpenID users, read refresh token from session to avoid large cookie issues */ + const refreshToken = req.session?.openidTokens?.refreshToken || parsedCookies.refreshToken; + + if (!refreshToken) { + return res.status(200).send('Refresh token not provided'); + } + try { const openIdConfig = getOpenIdConfig(); const tokenset = await openIdClient.refreshTokenGrant(openIdConfig, refreshToken); @@ -110,7 +113,7 @@ const refreshController = async (req, res) => { ); } - const token = setOpenIDAuthTokens(tokenset, res, user._id.toString(), refreshToken); + const token = setOpenIDAuthTokens(tokenset, req, res, user._id.toString(), refreshToken); user.federatedTokens = { access_token: tokenset.access_token, @@ -125,6 +128,13 @@ const refreshController = async (req, res) => { return res.status(403).send('Invalid OpenID refresh token'); } } + + /** For non-OpenID users, read refresh token from cookies */ + const refreshToken = parsedCookies.refreshToken; + if (!refreshToken) { + return res.status(200).send('Refresh token not provided'); + } + try { const payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET); const user = await getUserById(payload.id, '-password -__v -totpSecret -backupCodes'); diff --git a/api/server/controllers/auth/LogoutController.js b/api/server/controllers/auth/LogoutController.js index 02d3d0302d..ec66316285 100644 --- a/api/server/controllers/auth/LogoutController.js +++ b/api/server/controllers/auth/LogoutController.js @@ -5,15 +5,28 @@ const { logoutUser } = require('~/server/services/AuthService'); const { getOpenIdConfig } = require('~/strategies'); const logoutController = async (req, res) => { - const refreshToken = req.headers.cookie ? cookies.parse(req.headers.cookie).refreshToken : null; + const parsedCookies = req.headers.cookie ? cookies.parse(req.headers.cookie) : {}; + const isOpenIdUser = req.user?.openidId != null && req.user?.provider === 'openid'; + + /** For OpenID users, read refresh token from session; for others, use cookie */ + let refreshToken; + if (isOpenIdUser && req.session?.openidTokens) { + refreshToken = req.session.openidTokens.refreshToken; + delete req.session.openidTokens; + } + refreshToken = refreshToken || parsedCookies.refreshToken; + try { const logout = await logoutUser(req, refreshToken); const { status, message } = logout; + res.clearCookie('refreshToken'); + res.clearCookie('openid_access_token'); + res.clearCookie('openid_user_id'); res.clearCookie('token_provider'); const response = { message }; if ( - req.user.openidId != null && + isOpenIdUser && isEnabled(process.env.OPENID_USE_END_SESSION_ENDPOINT) && process.env.OPENID_ISSUER ) { @@ -27,7 +40,12 @@ const logoutController = async (req, res) => { ? openIdConfig.serverMetadata().end_session_endpoint : null; if (endSessionEndpoint) { - response.redirect = endSessionEndpoint; + const endSessionUrl = new URL(endSessionEndpoint); + /** Redirect back to app's login page after IdP logout */ + const postLogoutRedirectUri = + process.env.OPENID_POST_LOGOUT_REDIRECT_URI || `${process.env.DOMAIN_CLIENT}/login`; + endSessionUrl.searchParams.set('post_logout_redirect_uri', postLogoutRedirectUri); + response.redirect = endSessionUrl.toString(); } else { logger.warn( '[logoutController] end_session_endpoint not found in OpenID issuer metadata. Please verify that the issuer is correct.', diff --git a/api/server/middleware/validateImageRequest.js b/api/server/middleware/validateImageRequest.js index b74ed7225e..4d954d07c4 100644 --- a/api/server/middleware/validateImageRequest.js +++ b/api/server/middleware/validateImageRequest.js @@ -68,17 +68,11 @@ function createValidateImageRequest(secureImageLinks) { } const parsedCookies = cookies.parse(cookieHeader); - const refreshToken = parsedCookies.refreshToken; - - if (!refreshToken) { - logger.warn('[validateImageRequest] Token not provided'); - return res.status(401).send('Unauthorized'); - } - const tokenProvider = parsedCookies.token_provider; let userIdForPath; if (tokenProvider === 'openid' && isEnabled(process.env.OPENID_REUSE_TOKENS)) { + /** For OpenID users with OPENID_REUSE_TOKENS, use openid_user_id cookie */ const openidUserId = parsedCookies.openid_user_id; if (!openidUserId) { logger.warn('[validateImageRequest] No OpenID user ID cookie found'); @@ -92,6 +86,17 @@ function createValidateImageRequest(secureImageLinks) { } userIdForPath = validationResult.userId; } else { + /** + * For non-OpenID users (or OpenID without REUSE_TOKENS), use refreshToken from cookies. + * These users authenticate via setAuthTokens() which stores refreshToken in cookies. + */ + const refreshToken = parsedCookies.refreshToken; + + if (!refreshToken) { + logger.warn('[validateImageRequest] Token not provided'); + return res.status(401).send('Unauthorized'); + } + const validationResult = validateToken(refreshToken); if (!validationResult.valid) { logger.warn(`[validateImageRequest] ${validationResult.error}`); diff --git a/api/server/routes/oauth.js b/api/server/routes/oauth.js index 0b1252f636..64d29210ac 100644 --- a/api/server/routes/oauth.js +++ b/api/server/routes/oauth.js @@ -42,7 +42,7 @@ const oauthHandler = async (req, res, next) => { isEnabled(process.env.OPENID_REUSE_TOKENS) === true ) { await syncUserEntraGroupMemberships(req.user, req.user.tokenset.access_token); - setOpenIDAuthTokens(req.user.tokenset, res, req.user._id.toString()); + setOpenIDAuthTokens(req.user.tokenset, req, res, req.user._id.toString()); } else { await setAuthTokens(req.user._id, res); } diff --git a/api/server/services/AuthService.js b/api/server/services/AuthService.js index 0cb418e076..a400bce8b7 100644 --- a/api/server/services/AuthService.js +++ b/api/server/services/AuthService.js @@ -411,14 +411,17 @@ const setAuthTokens = async (userId, res, _session = null) => { /** * @function setOpenIDAuthTokens * Set OpenID Authentication Tokens - * //type tokenset from openid-client + * Stores tokens server-side in express-session to avoid large cookie sizes + * that can exceed HTTP/2 header limits (especially for users with many group memberships). + * * @param {import('openid-client').TokenEndpointResponse & import('openid-client').TokenEndpointResponseHelpers} tokenset * - The tokenset object containing access and refresh tokens + * @param {Object} req - request object (for session access) * @param {Object} res - response object * @param {string} [userId] - Optional MongoDB user ID for image path validation * @returns {String} - access token */ -const setOpenIDAuthTokens = (tokenset, res, userId, existingRefreshToken) => { +const setOpenIDAuthTokens = (tokenset, req, res, userId, existingRefreshToken) => { try { if (!tokenset) { logger.error('[setOpenIDAuthTokens] No tokenset found in request'); @@ -445,18 +448,30 @@ const setOpenIDAuthTokens = (tokenset, res, userId, existingRefreshToken) => { return; } - res.cookie('refreshToken', refreshToken, { - expires: expirationDate, - httpOnly: true, - secure: isProduction, - sameSite: 'strict', - }); - res.cookie('openid_access_token', tokenset.access_token, { - expires: expirationDate, - httpOnly: true, - secure: isProduction, - sameSite: 'strict', - }); + /** Store tokens server-side in session to avoid large cookies */ + if (req.session) { + req.session.openidTokens = { + accessToken: tokenset.access_token, + refreshToken: refreshToken, + expiresAt: expirationDate.getTime(), + }; + } else { + logger.warn('[setOpenIDAuthTokens] No session available, falling back to cookies'); + res.cookie('refreshToken', refreshToken, { + expires: expirationDate, + httpOnly: true, + secure: isProduction, + sameSite: 'strict', + }); + res.cookie('openid_access_token', tokenset.access_token, { + expires: expirationDate, + httpOnly: true, + secure: isProduction, + sameSite: 'strict', + }); + } + + /** Small cookie to indicate token provider (required for auth middleware) */ res.cookie('token_provider', 'openid', { expires: expirationDate, httpOnly: true, diff --git a/api/strategies/openIdJwtStrategy.js b/api/strategies/openIdJwtStrategy.js index 5d9eb14085..df318ca30e 100644 --- a/api/strategies/openIdJwtStrategy.js +++ b/api/strategies/openIdJwtStrategy.js @@ -81,10 +81,18 @@ const openIdJwtLogin = (openIdConfig) => { await updateUser(user.id, updateData); } - const cookieHeader = req.headers.cookie; - const parsedCookies = cookieHeader ? cookies.parse(cookieHeader) : {}; - const accessToken = parsedCookies.openid_access_token; - const refreshToken = parsedCookies.refreshToken; + /** Read tokens from session (server-side) to avoid large cookie issues */ + const sessionTokens = req.session?.openidTokens; + let accessToken = sessionTokens?.accessToken; + let refreshToken = sessionTokens?.refreshToken; + + /** Fallback to cookies for backward compatibility */ + if (!accessToken || !refreshToken) { + const cookieHeader = req.headers.cookie; + const parsedCookies = cookieHeader ? cookies.parse(cookieHeader) : {}; + accessToken = accessToken || parsedCookies.openid_access_token; + refreshToken = refreshToken || parsedCookies.refreshToken; + } user.federatedTokens = { access_token: accessToken || rawToken,