mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-04-03 06:17:21 +02:00
* feat: add resolveAppConfigForUser utility for tenant-scoped auth config
TypeScript utility in packages/api that wraps getAppConfig in
tenantStorage.run() when the user has a tenantId, falling back to
baseOnly for new users or non-tenant deployments. Uses DI pattern
(getAppConfig passed as parameter) for testability.
Auth flows apply role-level overrides only (userId not passed)
because user/group principal resolution is deferred to post-auth.
* feat: tenant-scoped app config in auth login flows
All auth strategies (LDAP, SAML, OpenID, social login) now use a
two-phase domain check consistent with requestPasswordReset:
1. Fast-fail with base config (memory-cached, zero DB queries)
2. DB user lookup
3. Tenant-scoped re-check via resolveAppConfigForUser (only when
user has a tenantId; otherwise reuse base config)
This preserves the original fast-fail protection against globally
blocked domains while enabling tenant-specific config overrides.
OpenID error ordering preserved: AUTH_FAILED checked before domain
re-check so users with wrong providers get the correct error type.
registerUser unchanged (baseOnly, no user identity yet).
* test: add tenant-scoped config tests for auth strategies
Add resolveAppConfig.spec.ts in packages/api with 8 tests:
- baseOnly fallback for null/undefined/no-tenant users
- tenant-scoped config with role and tenantId
- ALS context propagation verified inside getAppConfig callback
- undefined role with tenantId edge case
Update strategy and AuthService tests to mock resolveAppConfigForUser
via @librechat/api. Tests verify two-phase domain check behavior:
fast-fail before DB, tenant re-check after. Non-tenant users reuse
base config without calling resolveAppConfigForUser.
* refactor: skip redundant domain re-check for non-tenant users
Guard the second isEmailDomainAllowed call with appConfig !== baseConfig
in SAML, OpenID, and social strategies. For non-tenant users the tenant
config is the same base config object, so the second check is a no-op.
Narrow eslint-disable in resolveAppConfig.spec.ts to the specific
require line instead of blanket file-level suppression.
* fix: address review findings — consistency, tests, and ordering
- Consolidate duplicate require('@librechat/api') in AuthService.js
- Add two-phase domain check to LDAP (base fast-fail before findUser),
making all strategies consistent with PR description
- Add appConfig !== baseConfig guard to requestPasswordReset second
domain check, consistent with SAML/OpenID/social strategies
- Move SAML provider check before tenant config resolution to avoid
unnecessary resolveAppConfigForUser call for wrong-provider users
- Add tenant domain rejection tests to SAML, OpenID, and social specs
verifying that tenant config restrictions actually block login
- Add error propagation tests to resolveAppConfig.spec.ts
- Remove redundant mockTenantStorage alias in resolveAppConfig.spec.ts
- Narrow eslint-disable to specific require line
* test: add tenant domain rejection test for LDAP strategy
Covers the appConfig !== baseConfig && !isEmailDomainAllowed path,
consistent with SAML, OpenID, and social strategy specs.
* refactor: rename resolveAppConfig to app/resolve per AGENTS.md
Rename resolveAppConfig.ts → resolve.ts and
resolveAppConfig.spec.ts → resolve.spec.ts to align with
the project's concise naming convention.
* fix: remove fragile reference-equality guard, add logging and docs
Remove appConfig !== baseConfig guard from all strategies and
requestPasswordReset. The guard relied on implicit cache-backend
identity semantics (in-memory Keyv returns same object reference)
that would silently break with Redis or cloned configs. The second
isEmailDomainAllowed call is a cheap synchronous check — always
running it is clearer and eliminates the coupling.
Add audit logging to requestPasswordReset domain blocks (base and
tenant), consistent with all auth strategies.
Extract duplicated error construction into makeDomainDeniedError().
Wrap resolveAppConfigForUser in requestPasswordReset with try/catch
to prevent DB errors from leaking to the client via the controller's
generic catch handler.
Document the dual tenantId propagation (ALS for DB isolation,
explicit param for cache key) in resolveAppConfigForUser JSDoc.
Add comment documenting the LDAP error-type ordering change
(cross-provider users from blocked domains now get 'domain not
allowed' instead of AUTH_FAILED).
Assert resolveAppConfigForUser is not called on LDAP provider
mismatch path.
* fix: return generic response for tenant domain block in password reset
Tenant-scoped domain rejection in requestPasswordReset now returns the
same generic "If an account with that email exists..." response instead
of an Error. This prevents user-enumeration: an attacker cannot
distinguish between "email not found" and "tenant blocks this domain"
by comparing HTTP responses.
The base-config fast-fail (pre-user-lookup) still returns an Error
since it fires before any user existence is revealed.
* docs: document phase 1 vs phase 2 domain check behavior in JSDoc
Phase 1 (base config, pre-findUser) intentionally returns Error/400
to reveal globally blocked domains without confirming user existence.
Phase 2 (tenant config, post-findUser) returns generic 200 to prevent
user-enumeration. This distinction is now explicit in the JSDoc.
635 lines
20 KiB
JavaScript
635 lines
20 KiB
JavaScript
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,
|
|
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.';
|
|
|
|
/**
|
|
* 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<IUser>} user
|
|
* @returns {Promise<void>}
|
|
*/
|
|
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 <email, password, name, username>
|
|
* @param {Partial<IUser>} [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' };
|
|
};
|
|
|
|
/**
|
|
* Set Auth Tokens
|
|
* @param {String | ObjectId} userId
|
|
* @param {ServerResponse} res
|
|
* @param {ISession | null} [session=null]
|
|
* @returns
|
|
*/
|
|
const setAuthTokens = async (userId, res, _session = 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',
|
|
});
|
|
return token;
|
|
} catch (error) {
|
|
logger.error('[setAuthTokens] Error in setting authentication tokens:', error);
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* @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 {string} [userId] - Optional MongoDB user ID for image path validation
|
|
* @returns {String} - id_token (preferred) or access_token as the app auth token
|
|
*/
|
|
const setOpenIDAuthTokens = (tokenset, req, res, userId, existingRefreshToken) => {
|
|
try {
|
|
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 == null) {
|
|
logger.error('[setOpenIDAuthTokens] No tokenset found in request');
|
|
return;
|
|
}
|
|
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 appAuthToken = tokenset.id_token || tokenset.access_token;
|
|
|
|
/**
|
|
* 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: tokenset.id_token,
|
|
refreshToken: refreshToken,
|
|
expiresAt: expirationDate.getTime(),
|
|
};
|
|
} 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',
|
|
});
|
|
}
|
|
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,
|
|
requestPasswordReset,
|
|
resendVerificationEmail,
|
|
};
|