diff --git a/api/server/routes/admin/auth.js b/api/server/routes/admin/auth.js index 3e614c88ed..72f23b7d52 100644 --- a/api/server/routes/admin/auth.js +++ b/api/server/routes/admin/auth.js @@ -1,9 +1,8 @@ const express = require('express'); const passport = require('passport'); -const { randomState } = require('openid-client'); -const { logger } = require('@librechat/data-schemas'); +const crypto = require('node:crypto'); const { CacheKeys } = require('librechat-data-provider'); -const { SystemCapabilities } = require('@librechat/data-schemas'); +const { logger, SystemCapabilities } = require('@librechat/data-schemas'); const { getAdminPanelUrl, exchangeAdminCode, createSetBalanceConfig } = require('@librechat/api'); const { loginController } = require('~/server/controllers/auth/LoginController'); const { requireCapability } = require('~/server/middleware/roles/capabilities'); @@ -79,20 +78,81 @@ const PKCE_CHALLENGE_TTL = 5 * 60 * 1000; /** Regex pattern for valid PKCE challenges: 64 hex characters (SHA-256 hex digest) */ const PKCE_CHALLENGE_PATTERN = /^[a-f0-9]{64}$/; -router.get('/oauth/openid', async (req, res, next) => { - const state = randomState(); - const codeChallenge = req.query.code_challenge; +/** + * Generates a random hex state string for OAuth flows. + * @returns {string} A 32-byte random hex string. + */ +function generateState() { + return crypto.randomBytes(32).toString('hex'); +} - if (typeof codeChallenge === 'string' && PKCE_CHALLENGE_PATTERN.test(codeChallenge)) { +/** + * Stores a PKCE challenge in cache keyed by state. + * @param {string} state - The OAuth state value. + * @param {string | undefined} codeChallenge - The PKCE code_challenge from query params. + * @param {string} provider - Provider name for logging. + * @returns {Promise} True if stored successfully or no challenge provided. + */ +async function storePkceChallenge(state, codeChallenge, provider) { + if (typeof codeChallenge !== 'string' || !PKCE_CHALLENGE_PATTERN.test(codeChallenge)) { + return true; + } + try { + const cache = getLogStores(CacheKeys.ADMIN_OAUTH_EXCHANGE); + await cache.set(`pkce:${state}`, codeChallenge, PKCE_CHALLENGE_TTL); + return true; + } catch (err) { + logger.error(`[admin/oauth/${provider}] Failed to store PKCE challenge:`, err); + return false; + } +} + +/** + * Middleware to retrieve PKCE challenge from cache using the OAuth state. + * Reads state from req.oauthState (set by a preceding middleware). + * @param {string} provider - Provider name for logging. + * @returns {Function} Express middleware. + */ +function retrievePkceChallenge(provider) { + return async (req, res, next) => { + if (!req.oauthState) { + return next(); + } try { const cache = getLogStores(CacheKeys.ADMIN_OAUTH_EXCHANGE); - await cache.set(`pkce:${state}`, codeChallenge, PKCE_CHALLENGE_TTL); + const challenge = await cache.get(`pkce:${req.oauthState}`); + if (challenge) { + req.pkceChallenge = challenge; + await cache.delete(`pkce:${req.oauthState}`); + } else { + logger.warn( + `[admin/oauth/${provider}/callback] State present but no PKCE challenge found; PKCE will not be enforced for this request`, + ); + } } catch (err) { - logger.error('[admin/oauth/openid] Failed to store PKCE challenge:', err); + logger.error( + `[admin/oauth/${provider}/callback] Failed to retrieve PKCE challenge, aborting:`, + err, + ); return res.redirect( - `${getAdminPanelUrl()}/auth/openid/callback?error=pkce_store_failed&error_description=Failed+to+store+PKCE+challenge`, + `${getAdminPanelUrl()}/auth/${provider}/callback?error=pkce_retrieval_failed&error_description=Failed+to+retrieve+PKCE+challenge`, ); } + next(); + }; +} + +/* ────────────────────────────────────────────── + * OpenID Admin Routes + * ────────────────────────────────────────────── */ + +router.get('/oauth/openid', async (req, res, next) => { + const state = generateState(); + const stored = await storePkceChallenge(state, req.query.code_challenge, 'openid'); + if (!stored) { + return res.redirect( + `${getAdminPanelUrl()}/auth/openid/callback?error=pkce_store_failed&error_description=Failed+to+store+PKCE+challenge`, + ); } return passport.authenticate('openidAdmin', { @@ -112,35 +172,239 @@ router.get( failureMessage: true, session: false, }), - async (req, res, next) => { - if (!req.oauthState) { - return next(); - } - try { - const cache = getLogStores(CacheKeys.ADMIN_OAUTH_EXCHANGE); - const challenge = await cache.get(`pkce:${req.oauthState}`); - if (challenge) { - req.pkceChallenge = challenge; - await cache.delete(`pkce:${req.oauthState}`); - } else { - logger.warn( - '[admin/oauth/callback] State present but no PKCE challenge found; PKCE will not be enforced for this request', - ); - } - } catch (err) { - logger.error('[admin/oauth/callback] Failed to retrieve PKCE challenge, aborting:', err); - return res.redirect( - `${getAdminPanelUrl()}/auth/openid/callback?error=pkce_retrieval_failed&error_description=Failed+to+retrieve+PKCE+challenge`, - ); - } - next(); - }, + retrievePkceChallenge('openid'), requireAdminAccess, setBalanceConfig, middleware.checkDomainAllowed, createOAuthHandler(`${getAdminPanelUrl()}/auth/openid/callback`), ); +/* ────────────────────────────────────────────── + * SAML Admin Routes + * ────────────────────────────────────────────── */ + +router.get('/oauth/saml', async (req, res, next) => { + const state = generateState(); + const stored = await storePkceChallenge(state, req.query.code_challenge, 'saml'); + if (!stored) { + return res.redirect( + `${getAdminPanelUrl()}/auth/saml/callback?error=pkce_store_failed&error_description=Failed+to+store+PKCE+challenge`, + ); + } + + return passport.authenticate('samlAdmin', { + session: false, + additionalParams: { RelayState: state }, + })(req, res, next); +}); + +router.post( + '/oauth/saml/callback', + (req, res, next) => { + req.oauthState = typeof req.body.RelayState === 'string' ? req.body.RelayState : undefined; + next(); + }, + passport.authenticate('samlAdmin', { + failureRedirect: `${getAdminPanelUrl()}/auth/saml/callback?error=auth_failed&error_description=Authentication+failed`, + failureMessage: true, + session: false, + }), + retrievePkceChallenge('saml'), + requireAdminAccess, + setBalanceConfig, + middleware.checkDomainAllowed, + createOAuthHandler(`${getAdminPanelUrl()}/auth/saml/callback`), +); + +/* ────────────────────────────────────────────── + * Google Admin Routes + * ────────────────────────────────────────────── */ + +router.get('/oauth/google', async (req, res, next) => { + const state = generateState(); + const stored = await storePkceChallenge(state, req.query.code_challenge, 'google'); + if (!stored) { + return res.redirect( + `${getAdminPanelUrl()}/auth/google/callback?error=pkce_store_failed&error_description=Failed+to+store+PKCE+challenge`, + ); + } + + return passport.authenticate('googleAdmin', { + scope: ['openid', 'profile', 'email'], + session: false, + state, + })(req, res, next); +}); + +router.get( + '/oauth/google/callback', + (req, res, next) => { + req.oauthState = typeof req.query.state === 'string' ? req.query.state : undefined; + next(); + }, + passport.authenticate('googleAdmin', { + failureRedirect: `${getAdminPanelUrl()}/auth/google/callback?error=auth_failed&error_description=Authentication+failed`, + failureMessage: true, + session: false, + }), + retrievePkceChallenge('google'), + requireAdminAccess, + setBalanceConfig, + middleware.checkDomainAllowed, + createOAuthHandler(`${getAdminPanelUrl()}/auth/google/callback`), +); + +/* ────────────────────────────────────────────── + * GitHub Admin Routes + * ────────────────────────────────────────────── */ + +router.get('/oauth/github', async (req, res, next) => { + const state = generateState(); + const stored = await storePkceChallenge(state, req.query.code_challenge, 'github'); + if (!stored) { + return res.redirect( + `${getAdminPanelUrl()}/auth/github/callback?error=pkce_store_failed&error_description=Failed+to+store+PKCE+challenge`, + ); + } + + return passport.authenticate('githubAdmin', { + scope: ['user:email', 'read:user'], + session: false, + state, + })(req, res, next); +}); + +router.get( + '/oauth/github/callback', + (req, res, next) => { + req.oauthState = typeof req.query.state === 'string' ? req.query.state : undefined; + next(); + }, + passport.authenticate('githubAdmin', { + failureRedirect: `${getAdminPanelUrl()}/auth/github/callback?error=auth_failed&error_description=Authentication+failed`, + failureMessage: true, + session: false, + }), + retrievePkceChallenge('github'), + requireAdminAccess, + setBalanceConfig, + middleware.checkDomainAllowed, + createOAuthHandler(`${getAdminPanelUrl()}/auth/github/callback`), +); + +/* ────────────────────────────────────────────── + * Discord Admin Routes + * ────────────────────────────────────────────── */ + +router.get('/oauth/discord', async (req, res, next) => { + const state = generateState(); + const stored = await storePkceChallenge(state, req.query.code_challenge, 'discord'); + if (!stored) { + return res.redirect( + `${getAdminPanelUrl()}/auth/discord/callback?error=pkce_store_failed&error_description=Failed+to+store+PKCE+challenge`, + ); + } + + return passport.authenticate('discordAdmin', { + scope: ['identify', 'email'], + session: false, + state, + })(req, res, next); +}); + +router.get( + '/oauth/discord/callback', + (req, res, next) => { + req.oauthState = typeof req.query.state === 'string' ? req.query.state : undefined; + next(); + }, + passport.authenticate('discordAdmin', { + failureRedirect: `${getAdminPanelUrl()}/auth/discord/callback?error=auth_failed&error_description=Authentication+failed`, + failureMessage: true, + session: false, + }), + retrievePkceChallenge('discord'), + requireAdminAccess, + setBalanceConfig, + middleware.checkDomainAllowed, + createOAuthHandler(`${getAdminPanelUrl()}/auth/discord/callback`), +); + +/* ────────────────────────────────────────────── + * Facebook Admin Routes + * ────────────────────────────────────────────── */ + +router.get('/oauth/facebook', async (req, res, next) => { + const state = generateState(); + const stored = await storePkceChallenge(state, req.query.code_challenge, 'facebook'); + if (!stored) { + return res.redirect( + `${getAdminPanelUrl()}/auth/facebook/callback?error=pkce_store_failed&error_description=Failed+to+store+PKCE+challenge`, + ); + } + + return passport.authenticate('facebookAdmin', { + scope: ['public_profile'], + session: false, + state, + })(req, res, next); +}); + +router.get( + '/oauth/facebook/callback', + (req, res, next) => { + req.oauthState = typeof req.query.state === 'string' ? req.query.state : undefined; + next(); + }, + passport.authenticate('facebookAdmin', { + failureRedirect: `${getAdminPanelUrl()}/auth/facebook/callback?error=auth_failed&error_description=Authentication+failed`, + failureMessage: true, + session: false, + }), + retrievePkceChallenge('facebook'), + requireAdminAccess, + setBalanceConfig, + middleware.checkDomainAllowed, + createOAuthHandler(`${getAdminPanelUrl()}/auth/facebook/callback`), +); + +/* ────────────────────────────────────────────── + * Apple Admin Routes (POST callback) + * ────────────────────────────────────────────── */ + +router.get('/oauth/apple', async (req, res, next) => { + const state = generateState(); + const stored = await storePkceChallenge(state, req.query.code_challenge, 'apple'); + if (!stored) { + return res.redirect( + `${getAdminPanelUrl()}/auth/apple/callback?error=pkce_store_failed&error_description=Failed+to+store+PKCE+challenge`, + ); + } + + return passport.authenticate('appleAdmin', { + session: false, + state, + })(req, res, next); +}); + +router.post( + '/oauth/apple/callback', + (req, res, next) => { + req.oauthState = typeof req.body.state === 'string' ? req.body.state : undefined; + next(); + }, + passport.authenticate('appleAdmin', { + failureRedirect: `${getAdminPanelUrl()}/auth/apple/callback?error=auth_failed&error_description=Authentication+failed`, + failureMessage: true, + session: false, + }), + retrievePkceChallenge('apple'), + requireAdminAccess, + setBalanceConfig, + middleware.checkDomainAllowed, + createOAuthHandler(`${getAdminPanelUrl()}/auth/apple/callback`), +); + /** Regex pattern for valid exchange codes: 64 hex characters */ const EXCHANGE_CODE_PATTERN = /^[a-f0-9]{64}$/; diff --git a/api/server/socialLogins.js b/api/server/socialLogins.js index a84c33bd52..dfb03b4d37 100644 --- a/api/server/socialLogins.js +++ b/api/server/socialLogins.js @@ -6,11 +6,16 @@ const { logger, DEFAULT_SESSION_EXPIRY } = require('@librechat/data-schemas'); const { openIdJwtLogin, facebookLogin, + facebookAdminLogin, discordLogin, + discordAdminLogin, setupOpenId, googleLogin, + googleAdminLogin, githubLogin, + githubAdminLogin, appleLogin, + appleAdminLogin, setupSaml, } = require('~/strategies'); const { getLogStores } = require('~/cache'); @@ -58,18 +63,23 @@ const configureSocialLogins = async (app) => { if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET) { passport.use(googleLogin()); + passport.use('googleAdmin', googleAdminLogin()); } if (process.env.FACEBOOK_CLIENT_ID && process.env.FACEBOOK_CLIENT_SECRET) { passport.use(facebookLogin()); + passport.use('facebookAdmin', facebookAdminLogin()); } if (process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET) { passport.use(githubLogin()); + passport.use('githubAdmin', githubAdminLogin()); } if (process.env.DISCORD_CLIENT_ID && process.env.DISCORD_CLIENT_SECRET) { passport.use(discordLogin()); + passport.use('discordAdmin', discordAdminLogin()); } if (process.env.APPLE_CLIENT_ID && process.env.APPLE_PRIVATE_KEY_PATH) { passport.use(appleLogin()); + passport.use('appleAdmin', appleAdminLogin()); } if ( process.env.OPENID_CLIENT_ID && diff --git a/api/strategies/appleStrategy.js b/api/strategies/appleStrategy.js index fbba2a1f41..6eace87bae 100644 --- a/api/strategies/appleStrategy.js +++ b/api/strategies/appleStrategy.js @@ -34,16 +34,28 @@ const getProfileDetails = ({ idToken, profile }) => { // Initialize the social login handler for Apple const appleLogin = socialLogin('apple', getProfileDetails); +const appleAdminLogin = socialLogin('apple', getProfileDetails, { existingUsersOnly: true }); -module.exports = () => +const getAppleConfig = (callbackURL) => ({ + clientID: process.env.APPLE_CLIENT_ID, + teamID: process.env.APPLE_TEAM_ID, + callbackURL, + keyID: process.env.APPLE_KEY_ID, + privateKeyLocation: process.env.APPLE_PRIVATE_KEY_PATH, + passReqToCallback: false, +}); + +const appleStrategy = () => new AppleStrategy( - { - clientID: process.env.APPLE_CLIENT_ID, - teamID: process.env.APPLE_TEAM_ID, - callbackURL: `${process.env.DOMAIN_SERVER}${process.env.APPLE_CALLBACK_URL}`, - keyID: process.env.APPLE_KEY_ID, - privateKeyLocation: process.env.APPLE_PRIVATE_KEY_PATH, - passReqToCallback: false, // Set to true if you need to access the request in the callback - }, + getAppleConfig(`${process.env.DOMAIN_SERVER}${process.env.APPLE_CALLBACK_URL}`), appleLogin, ); + +const appleAdminStrategy = () => + new AppleStrategy( + getAppleConfig(`${process.env.DOMAIN_SERVER}/api/admin/oauth/apple/callback`), + appleAdminLogin, + ); + +module.exports = appleStrategy; +module.exports.appleAdminLogin = appleAdminStrategy; diff --git a/api/strategies/discordStrategy.js b/api/strategies/discordStrategy.js index dc7cb05ac6..7fb68280d5 100644 --- a/api/strategies/discordStrategy.js +++ b/api/strategies/discordStrategy.js @@ -22,15 +22,27 @@ const getProfileDetails = ({ profile }) => { }; const discordLogin = socialLogin('discord', getProfileDetails); +const discordAdminLogin = socialLogin('discord', getProfileDetails, { existingUsersOnly: true }); -module.exports = () => +const getDiscordConfig = (callbackURL) => ({ + clientID: process.env.DISCORD_CLIENT_ID, + clientSecret: process.env.DISCORD_CLIENT_SECRET, + callbackURL, + scope: ['identify', 'email'], + authorizationURL: 'https://discord.com/api/oauth2/authorize?prompt=none', +}); + +const discordStrategy = () => new DiscordStrategy( - { - clientID: process.env.DISCORD_CLIENT_ID, - clientSecret: process.env.DISCORD_CLIENT_SECRET, - callbackURL: `${process.env.DOMAIN_SERVER}${process.env.DISCORD_CALLBACK_URL}`, - scope: ['identify', 'email'], - authorizationURL: 'https://discord.com/api/oauth2/authorize?prompt=none', - }, + getDiscordConfig(`${process.env.DOMAIN_SERVER}${process.env.DISCORD_CALLBACK_URL}`), discordLogin, ); + +const discordAdminStrategy = () => + new DiscordStrategy( + getDiscordConfig(`${process.env.DOMAIN_SERVER}/api/admin/oauth/discord/callback`), + discordAdminLogin, + ); + +module.exports = discordStrategy; +module.exports.discordAdminLogin = discordAdminStrategy; diff --git a/api/strategies/facebookStrategy.js b/api/strategies/facebookStrategy.js index e5d1b054db..f638c3bfdb 100644 --- a/api/strategies/facebookStrategy.js +++ b/api/strategies/facebookStrategy.js @@ -11,16 +11,28 @@ const getProfileDetails = ({ profile }) => ({ }); const facebookLogin = socialLogin('facebook', getProfileDetails); +const facebookAdminLogin = socialLogin('facebook', getProfileDetails, { existingUsersOnly: true }); -module.exports = () => +const getFacebookConfig = (callbackURL) => ({ + clientID: process.env.FACEBOOK_CLIENT_ID, + clientSecret: process.env.FACEBOOK_CLIENT_SECRET, + callbackURL, + proxy: true, + scope: ['public_profile'], + profileFields: ['id', 'email', 'name'], +}); + +const facebookStrategy = () => new FacebookStrategy( - { - clientID: process.env.FACEBOOK_CLIENT_ID, - clientSecret: process.env.FACEBOOK_CLIENT_SECRET, - callbackURL: `${process.env.DOMAIN_SERVER}${process.env.FACEBOOK_CALLBACK_URL}`, - proxy: true, - scope: ['public_profile'], - profileFields: ['id', 'email', 'name'], - }, + getFacebookConfig(`${process.env.DOMAIN_SERVER}${process.env.FACEBOOK_CALLBACK_URL}`), facebookLogin, ); + +const facebookAdminStrategy = () => + new FacebookStrategy( + getFacebookConfig(`${process.env.DOMAIN_SERVER}/api/admin/oauth/facebook/callback`), + facebookAdminLogin, + ); + +module.exports = facebookStrategy; +module.exports.facebookAdminLogin = facebookAdminStrategy; diff --git a/api/strategies/githubStrategy.js b/api/strategies/githubStrategy.js index 1c3937381e..363acbfcdb 100644 --- a/api/strategies/githubStrategy.js +++ b/api/strategies/githubStrategy.js @@ -11,24 +11,36 @@ const getProfileDetails = ({ profile }) => ({ }); const githubLogin = socialLogin('github', getProfileDetails); +const githubAdminLogin = socialLogin('github', getProfileDetails, { existingUsersOnly: true }); -module.exports = () => +const getGitHubConfig = (callbackURL) => ({ + clientID: process.env.GITHUB_CLIENT_ID, + clientSecret: process.env.GITHUB_CLIENT_SECRET, + callbackURL, + proxy: false, + scope: ['user:email'], + ...(process.env.GITHUB_ENTERPRISE_BASE_URL && { + authorizationURL: `${process.env.GITHUB_ENTERPRISE_BASE_URL}/login/oauth/authorize`, + tokenURL: `${process.env.GITHUB_ENTERPRISE_BASE_URL}/login/oauth/access_token`, + userProfileURL: `${process.env.GITHUB_ENTERPRISE_BASE_URL}/api/v3/user`, + userEmailURL: `${process.env.GITHUB_ENTERPRISE_BASE_URL}/api/v3/user/emails`, + ...(process.env.GITHUB_ENTERPRISE_USER_AGENT && { + userAgent: process.env.GITHUB_ENTERPRISE_USER_AGENT, + }), + }), +}); + +const githubStrategy = () => new GitHubStrategy( - { - clientID: process.env.GITHUB_CLIENT_ID, - clientSecret: process.env.GITHUB_CLIENT_SECRET, - callbackURL: `${process.env.DOMAIN_SERVER}${process.env.GITHUB_CALLBACK_URL}`, - proxy: false, - scope: ['user:email'], - ...(process.env.GITHUB_ENTERPRISE_BASE_URL && { - authorizationURL: `${process.env.GITHUB_ENTERPRISE_BASE_URL}/login/oauth/authorize`, - tokenURL: `${process.env.GITHUB_ENTERPRISE_BASE_URL}/login/oauth/access_token`, - userProfileURL: `${process.env.GITHUB_ENTERPRISE_BASE_URL}/api/v3/user`, - userEmailURL: `${process.env.GITHUB_ENTERPRISE_BASE_URL}/api/v3/user/emails`, - ...(process.env.GITHUB_ENTERPRISE_USER_AGENT && { - userAgent: process.env.GITHUB_ENTERPRISE_USER_AGENT, - }), - }), - }, + getGitHubConfig(`${process.env.DOMAIN_SERVER}${process.env.GITHUB_CALLBACK_URL}`), githubLogin, ); + +const githubAdminStrategy = () => + new GitHubStrategy( + getGitHubConfig(`${process.env.DOMAIN_SERVER}/api/admin/oauth/github/callback`), + githubAdminLogin, + ); + +module.exports = githubStrategy; +module.exports.githubAdminLogin = githubAdminStrategy; diff --git a/api/strategies/googleStrategy.js b/api/strategies/googleStrategy.js index fd65823327..bee9a061a2 100644 --- a/api/strategies/googleStrategy.js +++ b/api/strategies/googleStrategy.js @@ -11,14 +11,26 @@ const getProfileDetails = ({ profile }) => ({ }); const googleLogin = socialLogin('google', getProfileDetails); +const googleAdminLogin = socialLogin('google', getProfileDetails, { existingUsersOnly: true }); -module.exports = () => +const getGoogleConfig = (callbackURL) => ({ + clientID: process.env.GOOGLE_CLIENT_ID, + clientSecret: process.env.GOOGLE_CLIENT_SECRET, + callbackURL, + proxy: true, +}); + +const googleStrategy = () => new GoogleStrategy( - { - clientID: process.env.GOOGLE_CLIENT_ID, - clientSecret: process.env.GOOGLE_CLIENT_SECRET, - callbackURL: `${process.env.DOMAIN_SERVER}${process.env.GOOGLE_CALLBACK_URL}`, - proxy: true, - }, + getGoogleConfig(`${process.env.DOMAIN_SERVER}${process.env.GOOGLE_CALLBACK_URL}`), googleLogin, ); + +const googleAdminStrategy = () => + new GoogleStrategy( + getGoogleConfig(`${process.env.DOMAIN_SERVER}/api/admin/oauth/google/callback`), + googleAdminLogin, + ); + +module.exports = googleStrategy; +module.exports.googleAdminLogin = googleAdminStrategy; diff --git a/api/strategies/index.js b/api/strategies/index.js index 9a1c58ad38..c15bbc4ce5 100644 --- a/api/strategies/index.js +++ b/api/strategies/index.js @@ -1,23 +1,33 @@ const { setupOpenId, getOpenIdConfig, getOpenIdEmail } = require('./openidStrategy'); const openIdJwtLogin = require('./openIdJwtStrategy'); const facebookLogin = require('./facebookStrategy'); +const { facebookAdminLogin } = facebookLogin; const discordLogin = require('./discordStrategy'); +const { discordAdminLogin } = discordLogin; const passportLogin = require('./localStrategy'); const googleLogin = require('./googleStrategy'); +const { googleAdminLogin } = googleLogin; const githubLogin = require('./githubStrategy'); +const { githubAdminLogin } = githubLogin; const { setupSaml } = require('./samlStrategy'); const appleLogin = require('./appleStrategy'); +const { appleAdminLogin } = appleLogin; const ldapLogin = require('./ldapStrategy'); const jwtLogin = require('./jwtStrategy'); module.exports = { appleLogin, + appleAdminLogin, passportLogin, googleLogin, + googleAdminLogin, githubLogin, + githubAdminLogin, discordLogin, + discordAdminLogin, jwtLogin, facebookLogin, + facebookAdminLogin, setupOpenId, getOpenIdConfig, getOpenIdEmail, diff --git a/api/strategies/samlStrategy.js b/api/strategies/samlStrategy.js index 21e7bdd001..4f4bfac158 100644 --- a/api/strategies/samlStrategy.js +++ b/api/strategies/samlStrategy.js @@ -178,137 +178,179 @@ function convertToUsername(input, defaultValue = '') { 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 = { - entryPoint: process.env.SAML_ENTRY_POINT, - issuer: process.env.SAML_ISSUER, + ...baseConfig, callbackUrl: process.env.SAML_CALLBACK_URL, - 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, }; - passport.use( - 'saml', - new SamlStrategy(samlConfig, 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) { - 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); - } - }), - ); + 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 }; diff --git a/api/strategies/samlStrategy.spec.js b/api/strategies/samlStrategy.spec.js index 2022d34b33..965fb157ef 100644 --- a/api/strategies/samlStrategy.spec.js +++ b/api/strategies/samlStrategy.spec.js @@ -58,10 +58,14 @@ jest.mocked(fs).existsSync = jest.fn(); jest.mocked(fs).statSync = jest.fn(); jest.mocked(fs).readFileSync = jest.fn(); -// To capture the verify callback from the strategy, we grab it from the mock constructor +// To capture the verify callback from the strategy, we grab it from the mock constructor. +// setupSaml() registers both 'saml' (regular) and 'samlAdmin' strategies, so we capture +// only the first callback per setupSaml() call (the regular one). let verifyCallback; SamlStrategy.mockImplementation((options, verify) => { - verifyCallback = verify; + if (!verifyCallback) { + verifyCallback = verify; + } return { name: 'saml', options, verify }; }); @@ -219,6 +223,8 @@ describe('setupSaml', () => { beforeEach(async () => { jest.clearAllMocks(); + // Reset so the mock captures the regular (non-admin) callback on next setupSaml() call + verifyCallback = null; // Configure mocks const { findUser, createUser, updateUser } = require('~/models'); diff --git a/api/strategies/socialLogin.js b/api/strategies/socialLogin.js index a5fe78e17d..580e4f3d7e 100644 --- a/api/strategies/socialLogin.js +++ b/api/strategies/socialLogin.js @@ -6,7 +6,8 @@ const { getAppConfig } = require('~/server/services/Config'); const { findUser } = require('~/models'); const socialLogin = - (provider, getProfileDetails) => async (accessToken, refreshToken, idToken, profile, cb) => { + (provider, getProfileDetails, options = {}) => + async (accessToken, refreshToken, idToken, profile, cb) => { try { const { email, id, avatarUrl, username, name, emailVerified } = getProfileDetails({ idToken, @@ -67,6 +68,13 @@ const socialLogin = return cb(error); } + if (options.existingUsersOnly) { + logger.error( + `[${provider}Login] Admin auth blocked - user does not exist [Email: ${email}]`, + ); + return cb(null, false, { message: 'User does not exist' }); + } + const ALLOW_SOCIAL_REGISTRATION = isEnabled(process.env.ALLOW_SOCIAL_REGISTRATION); if (!ALLOW_SOCIAL_REGISTRATION) { logger.error(