const bcrypt = require('bcryptjs'); const jwt = require('jsonwebtoken'); const { webcrypto } = require('node:crypto'); const { logger, DEFAULT_SESSION_EXPIRY, DEFAULT_REFRESH_TOKEN_EXPIRY, } = require('@librechat/data-schemas'); const { ErrorTypes, SystemRoles, errorsToString } = require('librechat-data-provider'); const { math, isEnabled, checkEmailConfig, setCloudFrontCookies, getCloudFrontConfig, parseCloudFrontCookieScope, CLOUDFRONT_SCOPE_COOKIE, isEmailDomainAllowed, shouldUseSecureCookie, resolveAppConfigForUser, } = require('@librechat/api'); const { findUser, findToken, createUser, updateUser, countUsers, getUserById, findSession, createToken, deleteTokens, deleteSession, createSession, generateToken, deleteUserById, generateRefreshToken, } = require('~/models'); const { registerSchema } = require('~/strategies/validators'); const { getAppConfig } = require('~/server/services/Config'); const { sendEmail } = require('~/server/utils'); const domains = { client: process.env.DOMAIN_CLIENT, server: process.env.DOMAIN_SERVER, }; const genericVerificationMessage = 'Please check your email to verify your email address.'; const OPENID_SESSION_ID_TOKEN_EXPIRY_BUFFER_SECONDS = 30; const getUnexpiredOpenIDSessionIdToken = (idToken) => { if (!idToken) { return; } const decoded = jwt.decode(idToken); const now = Math.floor(Date.now() / 1000); if ( decoded && typeof decoded === 'object' && decoded.exp > now + OPENID_SESSION_ID_TOKEN_EXPIRY_BUFFER_SECONDS ) { return idToken; } }; /** * Logout user * * @param {ServerRequest} req * @param {string} refreshToken * @returns */ const logoutUser = async (req, refreshToken) => { try { const userId = req.user._id; const session = await findSession({ userId: userId, refreshToken }); if (session) { try { await deleteSession({ sessionId: session._id }); } catch (deleteErr) { logger.error('[logoutUser] Failed to delete session.', deleteErr); return { status: 500, message: 'Failed to delete session.' }; } } try { req.session.destroy(); } catch (destroyErr) { logger.debug('[logoutUser] Failed to destroy session.', destroyErr); } return { status: 200, message: 'Logout successful' }; } catch (err) { return { status: 500, message: err.message }; } }; /** * Creates Token and corresponding Hash for verification * @returns {[string, string]} */ const createTokenHash = () => { const token = Buffer.from(webcrypto.getRandomValues(new Uint8Array(32))).toString('hex'); const hash = bcrypt.hashSync(token, 10); return [token, hash]; }; /** * Send Verification Email * @param {Partial} user * @returns {Promise} */ const sendVerificationEmail = async (user) => { const [verifyToken, hash] = createTokenHash(); const verificationLink = `${ domains.client }/verify?token=${verifyToken}&email=${encodeURIComponent(user.email)}`; await sendEmail({ email: user.email, subject: 'Verify your email', payload: { appName: process.env.APP_TITLE || 'LibreChat', name: user.name || user.username || user.email, verificationLink: verificationLink, year: new Date().getFullYear(), }, template: 'verifyEmail.handlebars', }); await createToken({ userId: user._id, email: user.email, token: hash, createdAt: Date.now(), expiresIn: 900, }); logger.info(`[sendVerificationEmail] Verification link issued. [Email: ${user.email}]`); }; /** * Verify Email * @param {ServerRequest} req */ const verifyEmail = async (req) => { const { email, token } = req.body; const decodedEmail = decodeURIComponent(email); const user = await findUser({ email: decodedEmail }, 'email _id emailVerified'); if (!user) { logger.warn(`[verifyEmail] [User not found] [Email: ${decodedEmail}]`); return new Error('User not found'); } if (user.emailVerified) { logger.info(`[verifyEmail] Email already verified [Email: ${decodedEmail}]`); return { message: 'Email already verified', status: 'success' }; } let emailVerificationData = await findToken({ email: decodedEmail }, { sort: { createdAt: -1 } }); if (!emailVerificationData) { logger.warn(`[verifyEmail] [No email verification data found] [Email: ${decodedEmail}]`); return new Error('Invalid or expired password reset token'); } const isValid = bcrypt.compareSync(token, emailVerificationData.token); if (!isValid) { logger.warn( `[verifyEmail] [Invalid or expired email verification token] [Email: ${decodedEmail}]`, ); return new Error('Invalid or expired email verification token'); } const updatedUser = await updateUser(emailVerificationData.userId, { emailVerified: true }); if (!updatedUser) { logger.warn(`[verifyEmail] [User update failed] [Email: ${decodedEmail}]`); return new Error('Failed to update user verification status'); } await deleteTokens({ token: emailVerificationData.token }); logger.info(`[verifyEmail] Email verification successful [Email: ${decodedEmail}]`); return { message: 'Email verification was successful', status: 'success' }; }; /** * Register a new user. * @param {IUser} user * @param {Partial} [additionalData={}] * @returns {Promise<{status: number, message: string, user?: IUser}>} */ const registerUser = async (user, additionalData = {}) => { const { error } = registerSchema.safeParse(user); if (error) { const errorMessage = errorsToString(error.errors); logger.info( 'Route: register - Validation Error', { name: 'Request params:', value: user }, { name: 'Validation error:', value: errorMessage }, ); return { status: 404, message: errorMessage }; } const { email, password, name, username, provider } = user; let newUserId; try { const appConfig = await getAppConfig({ baseOnly: true }); if (!isEmailDomainAllowed(email, appConfig?.registration?.allowedDomains)) { const errorMessage = 'The email address provided cannot be used. Please use a different email address.'; logger.error(`[registerUser] [Registration not allowed] [Email: ${user.email}]`); return { status: 403, message: errorMessage }; } const existingUser = await findUser({ email }, 'email _id'); if (existingUser) { logger.info( 'Register User - Email in use', { name: 'Request params:', value: user }, { name: 'Existing user:', value: existingUser }, ); // Sleep for 1 second await new Promise((resolve) => setTimeout(resolve, 1000)); return { status: 200, message: genericVerificationMessage }; } //determine if this is the first registered user (not counting anonymous_user) const isFirstRegisteredUser = (await countUsers()) === 0; const salt = bcrypt.genSaltSync(10); const newUserData = { provider: provider ?? 'local', email, username, name, avatar: null, role: isFirstRegisteredUser ? SystemRoles.ADMIN : SystemRoles.USER, password: bcrypt.hashSync(password, salt), ...additionalData, }; const emailEnabled = checkEmailConfig(); const disableTTL = isEnabled(process.env.ALLOW_UNVERIFIED_EMAIL_LOGIN); const newUser = await createUser(newUserData, appConfig.balance, disableTTL, true); newUserId = newUser._id; if (emailEnabled && !newUser.emailVerified) { await sendVerificationEmail({ _id: newUserId, email, name, }); } else { await updateUser(newUserId, { emailVerified: true }); } return { status: 200, message: genericVerificationMessage }; } catch (err) { logger.error('[registerUser] Error in registering user:', err); if (newUserId) { const result = await deleteUserById(newUserId); logger.warn( `[registerUser] [Email: ${email}] [Temporary User deleted: ${JSON.stringify(result)}]`, ); } return { status: 500, message: 'Something went wrong' }; } }; /** * Request password reset. * * Uses a two-phase domain check: fast-fail with the memory-cached base config * (zero DB queries) to block globally denied domains before user lookup, then * re-check with tenant-scoped config after user lookup so tenant-specific * restrictions are enforced. * * Phase 1 (base check) returns an Error (HTTP 400) — this intentionally reveals * that the domain is globally blocked, but fires before any DB lookup so it * cannot confirm user existence. Phase 2 (tenant check) returns the generic * success message (HTTP 200) to prevent user-enumeration via status codes. * * @param {ServerRequest} req */ const requestPasswordReset = async (req) => { const { email } = req.body; const baseConfig = await getAppConfig({ baseOnly: true }); if (!isEmailDomainAllowed(email, baseConfig?.registration?.allowedDomains)) { logger.warn( `[requestPasswordReset] Blocked - email domain not allowed [Email: ${email}] [IP: ${req.ip}]`, ); const error = new Error(ErrorTypes.AUTH_FAILED); error.code = ErrorTypes.AUTH_FAILED; error.message = 'Email domain not allowed'; return error; } const user = await findUser({ email }, 'email _id role tenantId'); let appConfig = baseConfig; if (user?.tenantId) { try { appConfig = await resolveAppConfigForUser(getAppConfig, user); } catch (err) { logger.error('[requestPasswordReset] Failed to resolve tenant config, using base:', err); } } if (!isEmailDomainAllowed(email, appConfig?.registration?.allowedDomains)) { logger.warn( `[requestPasswordReset] Tenant config blocked domain [Email: ${email}] [IP: ${req.ip}]`, ); return { message: 'If an account with that email exists, a password reset link has been sent to it.', }; } const emailEnabled = checkEmailConfig(); logger.warn(`[requestPasswordReset] [Password reset request initiated] [Email: ${email}]`); if (!user) { logger.warn(`[requestPasswordReset] [No user found] [Email: ${email}] [IP: ${req.ip}]`); return { message: 'If an account with that email exists, a password reset link has been sent to it.', }; } await deleteTokens({ userId: user._id }); const [resetToken, hash] = createTokenHash(); await createToken({ userId: user._id, token: hash, createdAt: Date.now(), expiresIn: 900, }); const link = `${domains.client}/reset-password?token=${resetToken}&userId=${user._id}`; if (emailEnabled) { await sendEmail({ email: user.email, subject: 'Password Reset Request', payload: { appName: process.env.APP_TITLE || 'LibreChat', name: user.name || user.username || user.email, link: link, year: new Date().getFullYear(), }, template: 'requestPasswordReset.handlebars', }); logger.info( `[requestPasswordReset] Link emailed. [Email: ${email}] [ID: ${user._id}] [IP: ${req.ip}]`, ); } else { logger.info( `[requestPasswordReset] Link issued. [Email: ${email}] [ID: ${user._id}] [IP: ${req.ip}]`, ); return { link }; } return { message: 'If an account with that email exists, a password reset link has been sent to it.', }; }; /** * Reset Password * * @param {*} userId * @param {String} token * @param {String} password * @returns */ const resetPassword = async (userId, token, password) => { let passwordResetToken = await findToken( { userId, }, { sort: { createdAt: -1 } }, ); if (!passwordResetToken) { return new Error('Invalid or expired password reset token'); } const isValid = bcrypt.compareSync(token, passwordResetToken.token); if (!isValid) { return new Error('Invalid or expired password reset token'); } const hash = bcrypt.hashSync(password, 10); const user = await updateUser(userId, { password: hash }); if (checkEmailConfig()) { await sendEmail({ email: user.email, subject: 'Password Reset Successfully', payload: { appName: process.env.APP_TITLE || 'LibreChat', name: user.name || user.username || user.email, year: new Date().getFullYear(), }, template: 'passwordReset.handlebars', }); } await deleteTokens({ token: passwordResetToken.token }); logger.info(`[resetPassword] Password reset successful. [Email: ${user.email}]`); return { message: 'Password reset was successful' }; }; /** * Reads the previously issued CloudFront cookie scope used for stale cookie cleanup. * @param {ServerRequest | null} [req=null] * @returns {import('@librechat/api').CloudFrontCookieScope | null} */ const getPreviousCloudFrontScope = (req) => parseCloudFrontCookieScope(req?.cookies?.[CLOUDFRONT_SCOPE_COOKIE]); const normalizeCloudFrontScopeValue = (value) => (value == null ? undefined : String(value)); const getCloudFrontScopeValue = (optionsValue, userValue, requestValue) => normalizeCloudFrontScopeValue(optionsValue ?? userValue ?? requestValue); const getCloudFrontAuthCookieSkipReason = (scope) => { const config = getCloudFrontConfig(); if (!config || config.imageSigning !== 'cookies' || !config.privateKey || !config.keyPairId) { return 'cloudfront_disabled'; } if (!config.cookieDomain) { return 'missing_cookie_domain'; } if (!scope.userId) { return 'missing_user_id'; } return null; }; /** * Refreshes CloudFront signed cookies for authenticated image/avatar access. * @param {ServerRequest | null} req * @param {ServerResponse} res * @param {Partial | null} user * @param {import('@librechat/api').CloudFrontCookieScope & { orgId?: string }} [options={}] * @returns {boolean} */ const setCloudFrontAuthCookies = (req, res, user, options = {}) => { const storageRegion = getCloudFrontScopeValue( options.storageRegion, user?.storageRegion, req?.user?.storageRegion, ); const scope = { userId: getCloudFrontScopeValue( options.userId, user?._id ?? user?.id, req?.user?._id ?? req?.user?.id, ), tenantId: getCloudFrontScopeValue( options.tenantId ?? options.orgId, user?.tenantId ?? user?.orgId, req?.user?.tenantId ?? req?.user?.orgId, ), ...(storageRegion ? { storageRegion } : {}), }; const skipReason = getCloudFrontAuthCookieSkipReason(scope); if (skipReason) { logger.debug('[setCloudFrontAuthCookies] CloudFront auth cookies skipped', { attempted: false, set: false, reason: skipReason, has_user_id: Boolean(scope.userId), has_tenant_scope: Boolean(scope.tenantId), has_storage_region: Boolean(scope.storageRegion), has_previous_scope: Boolean(getPreviousCloudFrontScope(req)?.userId), }); return false; } const previousScope = getPreviousCloudFrontScope(req); const cookiesSet = setCloudFrontCookies(res, scope, previousScope); logger.debug('[setCloudFrontAuthCookies] CloudFront auth cookies refreshed', { attempted: true, set: cookiesSet, reason: cookiesSet ? undefined : 'set_failed', has_user_id: true, has_tenant_scope: Boolean(scope.tenantId), has_storage_region: Boolean(scope.storageRegion), has_previous_scope: Boolean(previousScope?.userId), }); return cookiesSet; }; /** * Set Auth Tokens * @param {String | ObjectId} userId * @param {ServerResponse} res * @param {ISession | null} [_session=null] * @param {ServerRequest | null} [req=null] * @returns */ const setAuthTokens = async (userId, res, _session = null, req = null) => { try { let session = _session; let refreshToken; let refreshTokenExpires; const expiresIn = math(process.env.REFRESH_TOKEN_EXPIRY, DEFAULT_REFRESH_TOKEN_EXPIRY); if (session && session._id && session.expiration != null) { refreshTokenExpires = session.expiration.getTime(); refreshToken = await generateRefreshToken(session); } else { const result = await createSession(userId, { expiresIn }); session = result.session; refreshToken = result.refreshToken; refreshTokenExpires = session.expiration.getTime(); } const user = await getUserById(userId); const sessionExpiry = math(process.env.SESSION_EXPIRY, DEFAULT_SESSION_EXPIRY); const token = await generateToken(user, sessionExpiry); res.cookie('refreshToken', refreshToken, { expires: new Date(refreshTokenExpires), httpOnly: true, secure: shouldUseSecureCookie(), sameSite: 'strict', }); res.cookie('token_provider', 'librechat', { expires: new Date(refreshTokenExpires), httpOnly: true, secure: shouldUseSecureCookie(), sameSite: 'strict', }); setCloudFrontAuthCookies(req, res, user, { userId: user?._id ?? userId }); return token; } catch (error) { logger.error('[setAuthTokens] Error in setting authentication tokens:', error); throw error; } }; const resolveOpenIDAuthTokenOptions = (optionsOrUserId, existingRefreshToken, tenantId) => { if (optionsOrUserId != null && typeof optionsOrUserId === 'object') { if ( 'userId' in optionsOrUserId || 'existingRefreshToken' in optionsOrUserId || 'tenantId' in optionsOrUserId ) { return optionsOrUserId; } return {}; } return { userId: optionsOrUserId, existingRefreshToken, tenantId }; }; /** * @function setOpenIDAuthTokens * Set OpenID Authentication Tokens * 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 {Object} [options] - Optional token/cookie context * @param {string} [options.userId] - Optional MongoDB user ID for image path validation * @param {string} [options.existingRefreshToken] - Optional existing refresh token to preserve * @param {string} [options.tenantId] - Optional tenant identifier for CloudFront cookie scoping * @returns {String} - id_token (preferred) or access_token as the app auth token */ const setOpenIDAuthTokens = ( tokenset, req, res, optionsOrUserId = null, existingRefreshTokenArg, tenantIdArg, ) => { try { const { userId, existingRefreshToken, tenantId } = resolveOpenIDAuthTokenOptions( optionsOrUserId, existingRefreshTokenArg, tenantIdArg, ); if (!tokenset) { logger.error('[setOpenIDAuthTokens] No tokenset found in request'); return; } const expiryInMilliseconds = math( process.env.REFRESH_TOKEN_EXPIRY, DEFAULT_REFRESH_TOKEN_EXPIRY, ); const expirationDate = new Date(Date.now() + expiryInMilliseconds); if (!tokenset.access_token) { logger.error('[setOpenIDAuthTokens] No access token found in tokenset'); return; } const refreshToken = tokenset.refresh_token || existingRefreshToken; if (!refreshToken) { logger.error('[setOpenIDAuthTokens] No refresh token available'); return; } /** * Use id_token as the app authentication token (Bearer token for JWKS validation). * The id_token is always a standard JWT signed by the IdP's JWKS keys with the app's * client_id as audience. The access_token may be opaque or intended for a different * audience (e.g., Microsoft Graph API), which fails JWKS validation. * Falls back to access_token for providers where id_token is not available. */ const sessionIdToken = req.session?.openidTokens?.idToken; const appAuthToken = tokenset.id_token || getUnexpiredOpenIDSessionIdToken(sessionIdToken) || tokenset.access_token; const logoutIdToken = tokenset.id_token || sessionIdToken; /** * Always set refresh token cookie so it survives express session expiry. * The session cookie maxAge (SESSION_EXPIRY, default 15 min) is typically shorter * than the OIDC token lifetime (~1 hour). Without this cookie fallback, the refresh * token stored only in the session is lost when the session expires, causing the user * to be signed out on the next token refresh attempt. * The refresh token is small (opaque string) so it doesn't hit the HTTP/2 header * size limits that motivated session storage for the larger access_token/id_token. */ res.cookie('refreshToken', refreshToken, { expires: expirationDate, httpOnly: true, secure: shouldUseSecureCookie(), sameSite: 'strict', }); /** Store tokens server-side in session to avoid large cookies */ if (req.session) { req.session.openidTokens = { accessToken: tokenset.access_token, idToken: logoutIdToken, refreshToken: refreshToken, expiresAt: expirationDate.getTime(), lastRefreshedAt: Date.now(), }; } else { logger.warn('[setOpenIDAuthTokens] No session available, falling back to cookies'); res.cookie('openid_access_token', tokenset.access_token, { expires: expirationDate, httpOnly: true, secure: shouldUseSecureCookie(), sameSite: 'strict', }); if (tokenset.id_token) { res.cookie('openid_id_token', tokenset.id_token, { expires: expirationDate, httpOnly: true, secure: shouldUseSecureCookie(), sameSite: 'strict', }); } } /** Small cookie to indicate token provider (required for auth middleware) */ res.cookie('token_provider', 'openid', { expires: expirationDate, httpOnly: true, secure: shouldUseSecureCookie(), sameSite: 'strict', }); if (userId && isEnabled(process.env.OPENID_REUSE_TOKENS)) { /** JWT-signed user ID cookie for image path validation when OPENID_REUSE_TOKENS is enabled */ const signedUserId = jwt.sign({ id: userId }, process.env.JWT_REFRESH_SECRET, { expiresIn: expiryInMilliseconds / 1000, }); res.cookie('openid_user_id', signedUserId, { expires: expirationDate, httpOnly: true, secure: shouldUseSecureCookie(), sameSite: 'strict', }); } setCloudFrontAuthCookies(req, res, req.user, { userId, tenantId }); return appAuthToken; } catch (error) { logger.error('[setOpenIDAuthTokens] Error in setting authentication tokens:', error); throw error; } }; /** * Resend Verification Email * @param {Object} req * @param {Object} req.body * @param {String} req.body.email * @returns {Promise<{status: number, message: string}>} */ const resendVerificationEmail = async (req) => { try { const { email } = req.body; await deleteTokens({ email }); const user = await findUser({ email }, 'email _id name'); if (!user) { logger.warn(`[resendVerificationEmail] [No user found] [Email: ${email}]`); return { status: 200, message: genericVerificationMessage }; } const [verifyToken, hash] = createTokenHash(); const verificationLink = `${ domains.client }/verify?token=${verifyToken}&email=${encodeURIComponent(user.email)}`; await sendEmail({ email: user.email, subject: 'Verify your email', payload: { appName: process.env.APP_TITLE || 'LibreChat', name: user.name || user.username || user.email, verificationLink: verificationLink, year: new Date().getFullYear(), }, template: 'verifyEmail.handlebars', }); await createToken({ userId: user._id, email: user.email, token: hash, createdAt: Date.now(), expiresIn: 900, }); logger.info(`[resendVerificationEmail] Verification link issued. [Email: ${user.email}]`); return { status: 200, message: genericVerificationMessage, }; } catch (error) { logger.error(`[resendVerificationEmail] Error resending verification email: ${error.message}`); return { status: 500, message: 'Something went wrong.', }; } }; module.exports = { logoutUser, verifyEmail, registerUser, setAuthTokens, resetPassword, setOpenIDAuthTokens, setCloudFrontAuthCookies, requestPasswordReset, resendVerificationEmail, };