From 4f37e8adb9897678017bc1f4deb3922f936d8f5f Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 30 Mar 2026 22:49:44 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=90=20feat:=20Admin=20Auth=20Support?= =?UTF-8?q?=20for=20SAML=20and=20Social=20OAuth=20Providers=20(#12472)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- api/server/routes/admin/auth.js | 330 +++++++++++++++++++++++++--- api/server/socialLogins.js | 10 + api/strategies/appleStrategy.js | 30 ++- api/strategies/discordStrategy.js | 28 ++- api/strategies/facebookStrategy.js | 30 ++- api/strategies/githubStrategy.js | 46 ++-- api/strategies/googleStrategy.js | 26 ++- api/strategies/index.js | 10 + api/strategies/samlStrategy.js | 286 ++++++++++++++---------- api/strategies/samlStrategy.spec.js | 10 +- api/strategies/socialLogin.js | 10 +- 11 files changed, 608 insertions(+), 208 deletions(-) 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(