mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-04-02 22:07:19 +02:00
* refactor: Add existingUsersOnly support to social and SAML auth callbacks
- Add `existingUsersOnly` option to the `socialLogin` handler factory to
reject unknown users instead of creating new accounts
- Refactor SAML strategy callback into `createSamlCallback(existingUsersOnly)`
factory function, mirroring the OpenID `createOpenIDCallback` pattern
- Extract shared SAML config into `getBaseSamlConfig()` helper
- Register `samlAdmin` passport strategy with `existingUsersOnly: true` and
admin-specific callback URL, called automatically from `setupSaml()`
* feat: Register admin OAuth strategy variants for all social providers
- Add admin strategy exports to Google, GitHub, Discord, Facebook, and
Apple strategy files with admin callback URLs and existingUsersOnly
- Extract provider configs into reusable helpers to avoid duplication
between regular and admin strategy constructors
- Re-export all admin strategy factories from strategies/index.js
- Register admin passport strategies (googleAdmin, githubAdmin, etc.)
alongside regular ones in socialLogins.js when env vars are present
* feat: Add admin auth routes for SAML and social OAuth providers
- Add initiation and callback routes for SAML, Google, GitHub, Discord,
Facebook, and Apple to the admin auth router
- Each provider follows the exchange code + PKCE pattern established by
OpenID admin auth: store PKCE challenge on initiation, retrieve on
callback, generate exchange code for the admin panel
- SAML and Apple use POST callbacks with state extracted from
req.body.RelayState and req.body.state respectively
- Extract storePkceChallenge(), retrievePkceChallenge(), and
generateState() helpers; refactor existing OpenID routes to use them
- All callback chains enforce requireAdminAccess, setBalanceConfig,
checkDomainAllowed, and the shared createOAuthHandler
- No changes needed to the generic POST /oauth/exchange endpoint
* fix: Update SAML strategy test to handle dual strategy registration
setupSaml() now registers both 'saml' and 'samlAdmin' strategies,
causing the SamlStrategy mock to be called twice. The verifyCallback
variable was getting overwritten with the admin callback (which has
existingUsersOnly: true), making all new-user tests fail.
Fix: capture only the first callback per setupSaml() call and reset
between tests.
* fix: Address review findings for admin OAuth strategy changes
- Fix existingUsersOnly rejection in socialLogin.js to use
cb(null, false, { message }) instead of cb(error), ensuring
passport's failureRedirect fires correctly for admin flows
- Consolidate duplicate require() calls in strategies/index.js by
destructuring admin exports from the already-imported default export
- Pass pre-parsed baseConfig to setupSamlAdmin() to avoid redundant
certificate file I/O at startup
- Extract getGoogleConfig() helper in googleStrategy.js for consistency
with all other provider strategy files
- Replace randomState() (openid-client) with generateState() (crypto)
in the OpenID admin route for consistency with all other providers,
and remove the now-unused openid-client import
* Reorder import statements in auth.js
356 lines
12 KiB
JavaScript
356 lines
12 KiB
JavaScript
const fs = require('fs');
|
|
const path = require('path');
|
|
const fetch = require('node-fetch');
|
|
const passport = require('passport');
|
|
const { ErrorTypes } = require('librechat-data-provider');
|
|
const { hashToken, logger } = require('@librechat/data-schemas');
|
|
const { Strategy: SamlStrategy } = require('@node-saml/passport-saml');
|
|
const {
|
|
getBalanceConfig,
|
|
isEmailDomainAllowed,
|
|
resolveAppConfigForUser,
|
|
} = require('@librechat/api');
|
|
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
|
const { findUser, createUser, updateUser } = require('~/models');
|
|
const { getAppConfig } = require('~/server/services/Config');
|
|
const paths = require('~/config/paths');
|
|
|
|
let crypto;
|
|
try {
|
|
crypto = require('node:crypto');
|
|
} catch (err) {
|
|
logger.error('[samlStrategy] crypto support is disabled!', err);
|
|
}
|
|
|
|
/**
|
|
* Retrieves the certificate content from the given value.
|
|
*
|
|
* This function determines whether the provided value is a certificate string (RFC7468 format or
|
|
* base64-encoded without a header) or a valid file path. If the value matches one of these formats,
|
|
* the certificate content is returned. Otherwise, an error is thrown.
|
|
*
|
|
* @see https://github.com/node-saml/node-saml/tree/master?tab=readme-ov-file#configuration-option-idpcert
|
|
* @param {string} value - The certificate string or file path.
|
|
* @returns {string} The certificate content if valid.
|
|
* @throws {Error} If the value is not a valid certificate string or file path.
|
|
*/
|
|
function getCertificateContent(value) {
|
|
if (typeof value !== 'string') {
|
|
throw new Error('Invalid input: SAML_CERT must be a string.');
|
|
}
|
|
|
|
// Check if it's an RFC7468 formatted PEM certificate
|
|
const pemRegex = new RegExp(
|
|
'-----BEGIN (CERTIFICATE|PUBLIC KEY)-----\n' + // header
|
|
'([A-Za-z0-9+/=]{64}\n)+' + // base64 content (64 characters per line)
|
|
'[A-Za-z0-9+/=]{1,64}\n' + // base64 content (last line)
|
|
'-----END (CERTIFICATE|PUBLIC KEY)-----', // footer
|
|
);
|
|
if (pemRegex.test(value)) {
|
|
logger.info('[samlStrategy] Detected RFC7468-formatted certificate string.');
|
|
return value;
|
|
}
|
|
|
|
// Check if it's a Base64-encoded certificate (no header)
|
|
if (/^[A-Za-z0-9+/=]+$/.test(value) && value.length % 4 === 0) {
|
|
logger.info('[samlStrategy] Detected base64-encoded certificate string (no header).');
|
|
return value;
|
|
}
|
|
|
|
// Check if file exists and is readable
|
|
const certPath = path.normalize(path.isAbsolute(value) ? value : path.join(paths.root, value));
|
|
if (fs.existsSync(certPath) && fs.statSync(certPath).isFile()) {
|
|
try {
|
|
logger.info(`[samlStrategy] Loading certificate from file: ${certPath}`);
|
|
return fs.readFileSync(certPath, 'utf8').trim();
|
|
} catch (error) {
|
|
throw new Error(`Error reading certificate file: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
throw new Error('Invalid cert: SAML_CERT must be a valid file path or certificate string.');
|
|
}
|
|
|
|
/**
|
|
* Retrieves a SAML claim from a profile object based on environment configuration.
|
|
* @param {object} profile - Saml profile
|
|
* @param {string} envVar - Environment variable name (SAML_*)
|
|
* @param {string} defaultKey - Default key to use if the environment variable is not set
|
|
* @returns {string}
|
|
*/
|
|
function getSamlClaim(profile, envVar, defaultKey) {
|
|
const claimKey = process.env[envVar];
|
|
|
|
// Avoids accessing `profile[""]` when the environment variable is empty string.
|
|
if (claimKey) {
|
|
return profile[claimKey] ?? profile[defaultKey];
|
|
}
|
|
return profile[defaultKey];
|
|
}
|
|
|
|
function getEmail(profile) {
|
|
return getSamlClaim(profile, 'SAML_EMAIL_CLAIM', 'email');
|
|
}
|
|
|
|
function getUserName(profile) {
|
|
return getSamlClaim(profile, 'SAML_USERNAME_CLAIM', 'username');
|
|
}
|
|
|
|
function getGivenName(profile) {
|
|
return getSamlClaim(profile, 'SAML_GIVEN_NAME_CLAIM', 'given_name');
|
|
}
|
|
|
|
function getFamilyName(profile) {
|
|
return getSamlClaim(profile, 'SAML_FAMILY_NAME_CLAIM', 'family_name');
|
|
}
|
|
|
|
function getPicture(profile) {
|
|
return getSamlClaim(profile, 'SAML_PICTURE_CLAIM', 'picture');
|
|
}
|
|
|
|
/**
|
|
* Downloads an image from a URL using an access token.
|
|
* @param {string} url
|
|
* @returns {Promise<Buffer>}
|
|
*/
|
|
const downloadImage = async (url) => {
|
|
try {
|
|
const response = await fetch(url);
|
|
if (response.ok) {
|
|
return await response.buffer();
|
|
} else {
|
|
throw new Error(`${response.statusText} (HTTP ${response.status})`);
|
|
}
|
|
} catch (error) {
|
|
logger.error(`[samlStrategy] Error downloading image at URL "${url}": ${error}`);
|
|
return null;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Determines the full name of a user based on SAML profile and environment configuration.
|
|
*
|
|
* @param {Object} profile - The user profile object from SAML Connect
|
|
* @returns {string} The determined full name of the user
|
|
*/
|
|
function getFullName(profile) {
|
|
if (process.env.SAML_NAME_CLAIM) {
|
|
logger.info(
|
|
`[samlStrategy] Using SAML_NAME_CLAIM: ${process.env.SAML_NAME_CLAIM}, profile: ${profile[process.env.SAML_NAME_CLAIM]}`,
|
|
);
|
|
return profile[process.env.SAML_NAME_CLAIM];
|
|
}
|
|
|
|
const givenName = getGivenName(profile);
|
|
const familyName = getFamilyName(profile);
|
|
|
|
if (givenName && familyName) {
|
|
return `${givenName} ${familyName}`;
|
|
}
|
|
|
|
if (givenName) {
|
|
return givenName;
|
|
}
|
|
if (familyName) {
|
|
return familyName;
|
|
}
|
|
|
|
return getUserName(profile) || getEmail(profile);
|
|
}
|
|
|
|
/**
|
|
* Converts an input into a string suitable for a username.
|
|
* If the input is a string, it will be returned as is.
|
|
* If the input is an array, elements will be joined with underscores.
|
|
* In case of undefined or other falsy values, a default value will be returned.
|
|
*
|
|
* @param {string | string[] | undefined} input - The input value to be converted into a username.
|
|
* @param {string} [defaultValue=''] - The default value to return if the input is falsy.
|
|
* @returns {string} The processed input as a string suitable for a username.
|
|
*/
|
|
function convertToUsername(input, defaultValue = '') {
|
|
if (typeof input === 'string') {
|
|
return input;
|
|
} else if (Array.isArray(input)) {
|
|
return input.join('_');
|
|
}
|
|
|
|
return defaultValue;
|
|
}
|
|
|
|
/**
|
|
* Creates a SAML authentication callback.
|
|
* @param {boolean} [existingUsersOnly=false] - If true, only existing users will be authenticated.
|
|
* @returns {Function} The SAML callback function for passport.
|
|
*/
|
|
function createSamlCallback(existingUsersOnly = false) {
|
|
return async (profile, done) => {
|
|
try {
|
|
logger.info(`[samlStrategy] SAML authentication received for NameID: ${profile.nameID}`);
|
|
logger.debug('[samlStrategy] SAML profile:', profile);
|
|
|
|
const userEmail = getEmail(profile) || '';
|
|
|
|
const baseConfig = await getAppConfig({ baseOnly: true });
|
|
if (!isEmailDomainAllowed(userEmail, baseConfig?.registration?.allowedDomains)) {
|
|
logger.error(
|
|
`[SAML Strategy] Authentication blocked - email domain not allowed [Email: ${userEmail}]`,
|
|
);
|
|
return done(null, false, { message: 'Email domain not allowed' });
|
|
}
|
|
|
|
let user = await findUser({ samlId: profile.nameID });
|
|
logger.info(
|
|
`[samlStrategy] User ${user ? 'found' : 'not found'} with SAML ID: ${profile.nameID}`,
|
|
);
|
|
|
|
if (!user) {
|
|
user = await findUser({ email: userEmail });
|
|
logger.info(`[samlStrategy] User ${user ? 'found' : 'not found'} with email: ${userEmail}`);
|
|
}
|
|
|
|
if (user && user.provider !== 'saml') {
|
|
logger.info(
|
|
`[samlStrategy] User ${user.email} already exists with provider ${user.provider}`,
|
|
);
|
|
return done(null, false, {
|
|
message: ErrorTypes.AUTH_FAILED,
|
|
});
|
|
}
|
|
|
|
const appConfig = user?.tenantId
|
|
? await resolveAppConfigForUser(getAppConfig, user)
|
|
: baseConfig;
|
|
|
|
if (!isEmailDomainAllowed(userEmail, appConfig?.registration?.allowedDomains)) {
|
|
logger.error(
|
|
`[SAML Strategy] Authentication blocked - email domain not allowed [Email: ${userEmail}]`,
|
|
);
|
|
return done(null, false, { message: 'Email domain not allowed' });
|
|
}
|
|
|
|
const fullName = getFullName(profile);
|
|
|
|
const username = convertToUsername(
|
|
getUserName(profile) || getGivenName(profile) || getEmail(profile),
|
|
);
|
|
|
|
if (!user) {
|
|
if (existingUsersOnly) {
|
|
logger.error(
|
|
`[samlStrategy] Admin auth blocked - user does not exist [Email: ${userEmail}]`,
|
|
);
|
|
return done(null, false, { message: 'User does not exist' });
|
|
}
|
|
|
|
user = {
|
|
provider: 'saml',
|
|
samlId: profile.nameID,
|
|
username,
|
|
email: userEmail,
|
|
emailVerified: true,
|
|
name: fullName,
|
|
};
|
|
const balanceConfig = getBalanceConfig(appConfig);
|
|
user = await createUser(user, balanceConfig, true, true);
|
|
} else {
|
|
user.provider = 'saml';
|
|
user.samlId = profile.nameID;
|
|
user.username = username;
|
|
user.name = fullName;
|
|
}
|
|
|
|
const picture = getPicture(profile);
|
|
if (picture && !user.avatar?.includes('manual=true')) {
|
|
const imageBuffer = await downloadImage(profile.picture);
|
|
if (imageBuffer) {
|
|
let fileName;
|
|
if (crypto) {
|
|
fileName = (await hashToken(profile.nameID)) + '.png';
|
|
} else {
|
|
fileName = profile.nameID + '.png';
|
|
}
|
|
|
|
const { saveBuffer } = getStrategyFunctions(
|
|
appConfig?.fileStrategy ?? process.env.CDN_PROVIDER,
|
|
);
|
|
const imagePath = await saveBuffer({
|
|
fileName,
|
|
userId: user._id.toString(),
|
|
buffer: imageBuffer,
|
|
});
|
|
user.avatar = imagePath ?? '';
|
|
}
|
|
}
|
|
|
|
user = await updateUser(user._id, user);
|
|
|
|
logger.info(
|
|
`[samlStrategy] Login success SAML ID: ${user.samlId} | email: ${user.email} | username: ${user.username}`,
|
|
{
|
|
user: {
|
|
samlId: user.samlId,
|
|
username: user.username,
|
|
email: user.email,
|
|
name: user.name,
|
|
},
|
|
},
|
|
);
|
|
|
|
done(null, user);
|
|
} catch (err) {
|
|
logger.error('[samlStrategy] Login failed', err);
|
|
done(err);
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Returns the base SAML configuration shared by both regular and admin strategies.
|
|
* @returns {object} The SAML configuration object.
|
|
*/
|
|
function getBaseSamlConfig() {
|
|
return {
|
|
entryPoint: process.env.SAML_ENTRY_POINT,
|
|
issuer: process.env.SAML_ISSUER,
|
|
idpCert: getCertificateContent(process.env.SAML_CERT),
|
|
wantAssertionsSigned: process.env.SAML_USE_AUTHN_RESPONSE_SIGNED === 'true' ? false : true,
|
|
wantAuthnResponseSigned: process.env.SAML_USE_AUTHN_RESPONSE_SIGNED === 'true' ? true : false,
|
|
};
|
|
}
|
|
|
|
async function setupSaml() {
|
|
try {
|
|
const baseConfig = getBaseSamlConfig();
|
|
const samlConfig = {
|
|
...baseConfig,
|
|
callbackUrl: process.env.SAML_CALLBACK_URL,
|
|
};
|
|
|
|
passport.use('saml', new SamlStrategy(samlConfig, createSamlCallback(false)));
|
|
setupSamlAdmin(baseConfig);
|
|
} catch (err) {
|
|
logger.error('[samlStrategy]', err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sets up the SAML strategy specifically for admin authentication.
|
|
* Rejects users that don't already exist.
|
|
* @param {object} [baseConfig] - Pre-parsed base SAML config to avoid redundant cert parsing.
|
|
*/
|
|
function setupSamlAdmin(baseConfig) {
|
|
try {
|
|
const samlAdminConfig = {
|
|
...(baseConfig ?? getBaseSamlConfig()),
|
|
callbackUrl: `${process.env.DOMAIN_SERVER}/api/admin/oauth/saml/callback`,
|
|
};
|
|
|
|
passport.use('samlAdmin', new SamlStrategy(samlAdminConfig, createSamlCallback(true)));
|
|
logger.info('[samlStrategy] Admin SAML strategy registered.');
|
|
} catch (err) {
|
|
logger.error('[samlStrategy] setupSamlAdmin', err);
|
|
}
|
|
}
|
|
|
|
module.exports = { setupSaml, getCertificateContent };
|