📁 feat: Integrate SharePoint File Picker and Download Workflow (#8651)

* feat(sharepoint): integrate SharePoint file picker and download workflow
Introduces end‑to‑end SharePoint import support:
* Token exchange with Microsoft Graph and scope management (`useSharePointToken`)
* Re‑usable hooks: `useSharePointPicker`, `useSharePointDownload`,
  `useSharePointFileHandling`
* FileSearch dropdown now offers **From Local Machine** / **From SharePoint**
  sources and gracefully falls back when SharePoint is disabled
* Agent upload model, `AttachFileMenu`, and `DropdownPopup` extended for
  SharePoint files and sub‑menus
* Blurry overlay with progress indicator and `maxSelectionCount` limit during
  downloads
* Cache‑flush utility (`config/flush-cache.js`) supporting Redis & filesystem,
  with dry‑run and npm script
* Updated `SharePointIcon` (uses `currentColor`) and new i18n keys
* Bug fixes: placeholder syntax in progress message, picker event‑listener
  cleanup
* Misc style and performance optimizations

* Fix ESLint warnings

---------

Co-authored-by: Atef Bellaaj <slalom.bellaaj@external.daimlertruck.com>
This commit is contained in:
Danny Avila 2025-07-25 00:03:23 -04:00
parent b6413b06bc
commit a955097faf
No known key found for this signature in database
GPG key ID: BF31EEB2C5CA0956
40 changed files with 2500 additions and 123 deletions

View file

@ -12,6 +12,7 @@ const {
} = require('~/server/services/AuthService');
const { findUser, getUserById, deleteAllUserSessions, findSession } = require('~/models');
const { getOpenIdConfig } = require('~/strategies');
const { getGraphApiToken } = require('~/server/services/GraphTokenService');
const registrationController = async (req, res) => {
try {
@ -118,9 +119,54 @@ const refreshController = async (req, res) => {
}
};
const graphTokenController = async (req, res) => {
try {
// Validate user is authenticated via Entra ID
if (!req.user.openidId || req.user.provider !== 'openid') {
return res.status(403).json({
message: 'Microsoft Graph access requires Entra ID authentication',
});
}
// Check if OpenID token reuse is active (required for on-behalf-of flow)
if (!isEnabled(process.env.OPENID_REUSE_TOKENS)) {
return res.status(403).json({
message: 'SharePoint integration requires OpenID token reuse to be enabled',
});
}
// Extract access token from Authorization header
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({
message: 'Valid authorization token required',
});
}
// Get scopes from query parameters
const scopes = req.query.scopes;
if (!scopes) {
return res.status(400).json({
message: 'Graph API scopes are required as query parameter',
});
}
const accessToken = authHeader.substring(7); // Remove 'Bearer ' prefix
const tokenResponse = await getGraphApiToken(req.user, accessToken, scopes);
res.json(tokenResponse);
} catch (error) {
logger.error('[graphTokenController] Failed to obtain Graph API token:', error);
res.status(500).json({
message: 'Failed to obtain Microsoft Graph token',
});
}
};
module.exports = {
refreshController,
registrationController,
resetPasswordController,
resetPasswordRequestController,
graphTokenController,
};

View file

@ -4,6 +4,7 @@ const {
registrationController,
resetPasswordController,
resetPasswordRequestController,
graphTokenController,
} = require('~/server/controllers/AuthController');
const { loginController } = require('~/server/controllers/auth/LoginController');
const { logoutController } = require('~/server/controllers/auth/LogoutController');
@ -69,4 +70,6 @@ router.post('/2fa/confirm', requireJwtAuth, confirm2FA);
router.post('/2fa/disable', requireJwtAuth, disable2FA);
router.post('/2fa/backup/regenerate', requireJwtAuth, regenerateBackupCodes);
router.get('/graph-token', requireJwtAuth, graphTokenController);
module.exports = router;

View file

@ -21,6 +21,9 @@ const publicSharedLinksEnabled =
(process.env.ALLOW_SHARED_LINKS_PUBLIC === undefined ||
isEnabled(process.env.ALLOW_SHARED_LINKS_PUBLIC));
const sharePointFilePickerEnabled = isEnabled(process.env.ENABLE_SHAREPOINT_FILEPICKER);
const openidReuseTokens = isEnabled(process.env.OPENID_REUSE_TOKENS);
router.get('/', async function (req, res) {
const cache = getLogStores(CacheKeys.CONFIG_STORE);
@ -98,6 +101,11 @@ router.get('/', async function (req, res) {
instanceProjectId: instanceProject._id.toString(),
bundlerURL: process.env.SANDPACK_BUNDLER_URL,
staticBundlerURL: process.env.SANDPACK_STATIC_BUNDLER_URL,
sharePointFilePickerEnabled,
sharePointBaseUrl: process.env.SHAREPOINT_BASE_URL,
sharePointPickerGraphScope: process.env.SHAREPOINT_PICKER_GRAPH_SCOPE,
sharePointPickerSharePointScope: process.env.SHAREPOINT_PICKER_SHAREPOINT_SCOPE,
openidReuseTokens,
};
payload.mcpServers = {};

View file

@ -0,0 +1,86 @@
const { getOpenIdConfig } = require('~/strategies/openidStrategy');
const { logger } = require('~/config');
const { CacheKeys } = require('librechat-data-provider');
const getLogStores = require('~/cache/getLogStores');
const client = require('openid-client');
/**
* Get Microsoft Graph API token using existing token exchange mechanism
* @param {Object} user - User object with OpenID information
* @param {string} accessToken - Current access token from Authorization header
* @param {string} scopes - Graph API scopes for the token
* @param {boolean} fromCache - Whether to try getting token from cache first
* @returns {Promise<Object>} Graph API token response with access_token and expires_in
*/
async function getGraphApiToken(user, accessToken, scopes, fromCache = true) {
try {
if (!user.openidId) {
throw new Error('User must be authenticated via Entra ID to access Microsoft Graph');
}
if (!accessToken) {
throw new Error('Access token is required for token exchange');
}
if (!scopes) {
throw new Error('Graph API scopes are required for token exchange');
}
const config = getOpenIdConfig();
if (!config) {
throw new Error('OpenID configuration not available');
}
const cacheKey = `${user.openidId}:${scopes}`;
const tokensCache = getLogStores(CacheKeys.OPENID_EXCHANGED_TOKENS);
if (fromCache) {
const cachedToken = await tokensCache.get(cacheKey);
if (cachedToken) {
logger.debug(`[GraphTokenService] Using cached Graph API token for user: ${user.openidId}`);
return cachedToken;
}
}
logger.debug(`[GraphTokenService] Requesting new Graph API token for user: ${user.openidId}`);
logger.debug(`[GraphTokenService] Requested scopes: ${scopes}`);
const grantResponse = await client.genericGrantRequest(
config,
'urn:ietf:params:oauth:grant-type:jwt-bearer',
{
scope: scopes,
assertion: accessToken,
requested_token_use: 'on_behalf_of',
},
);
const tokenResponse = {
access_token: grantResponse.access_token,
token_type: 'Bearer',
expires_in: grantResponse.expires_in || 3600,
scope: scopes,
};
await tokensCache.set(
cacheKey,
tokenResponse,
(grantResponse.expires_in || 3600) * 1000, // Convert to milliseconds
);
logger.debug(
`[GraphTokenService] Successfully obtained and cached Graph API token for user: ${user.openidId}`,
);
return tokenResponse;
} catch (error) {
logger.error(
`[GraphTokenService] Failed to acquire Graph API token for user ${user.openidId}:`,
error,
);
throw new Error(`Graph token acquisition failed: ${error.message}`);
}
}
module.exports = {
getGraphApiToken,
};