From cca9d63224183308af57a5ce541c39cb76decb3a Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sat, 21 Feb 2026 18:03:39 -0500 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=92=20refactor:=20`graphTokenControlle?= =?UTF-8?q?r`=20to=20use=20federated=20access=20token=20for=20OBO=20assert?= =?UTF-8?q?ion=20(#11893)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Removed the extraction of access token from the Authorization header. - Implemented logic to use the federated access token from the user object. - Added error handling for missing federated access token. - Updated related documentation in GraphTokenService to reflect changes in access token usage. - Introduced unit tests for various scenarios in AuthController.spec.js to ensure proper functionality. --- api/server/controllers/AuthController.js | 17 +-- api/server/controllers/AuthController.spec.js | 144 ++++++++++++++++++ api/server/services/GraphTokenService.js | 2 +- 3 files changed, 152 insertions(+), 11 deletions(-) create mode 100644 api/server/controllers/AuthController.spec.js diff --git a/api/server/controllers/AuthController.js b/api/server/controllers/AuthController.js index 588391b535..58d2427512 100644 --- a/api/server/controllers/AuthController.js +++ b/api/server/controllers/AuthController.js @@ -196,15 +196,6 @@ const graphTokenController = async (req, res) => { }); } - // 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({ @@ -212,7 +203,13 @@ const graphTokenController = async (req, res) => { }); } - const accessToken = authHeader.substring(7); // Remove 'Bearer ' prefix + const accessToken = req.user.federatedTokens?.access_token; + if (!accessToken) { + return res.status(401).json({ + message: 'No federated access token available for token exchange', + }); + } + const tokenResponse = await getGraphApiToken(req.user, accessToken, scopes); res.json(tokenResponse); diff --git a/api/server/controllers/AuthController.spec.js b/api/server/controllers/AuthController.spec.js new file mode 100644 index 0000000000..cbf72657fb --- /dev/null +++ b/api/server/controllers/AuthController.spec.js @@ -0,0 +1,144 @@ +jest.mock('@librechat/data-schemas', () => ({ + logger: { error: jest.fn(), debug: jest.fn(), warn: jest.fn() }, +})); +jest.mock('~/server/services/GraphTokenService', () => ({ + getGraphApiToken: jest.fn(), +})); +jest.mock('~/server/services/AuthService', () => ({ + requestPasswordReset: jest.fn(), + setOpenIDAuthTokens: jest.fn(), + resetPassword: jest.fn(), + setAuthTokens: jest.fn(), + registerUser: jest.fn(), +})); +jest.mock('~/strategies', () => ({ getOpenIdConfig: jest.fn() })); +jest.mock('~/models', () => ({ + deleteAllUserSessions: jest.fn(), + getUserById: jest.fn(), + findSession: jest.fn(), + updateUser: jest.fn(), + findUser: jest.fn(), +})); +jest.mock('@librechat/api', () => ({ + isEnabled: jest.fn(), + findOpenIDUser: jest.fn(), +})); + +const { isEnabled } = require('@librechat/api'); +const { getGraphApiToken } = require('~/server/services/GraphTokenService'); +const { graphTokenController } = require('./AuthController'); + +describe('graphTokenController', () => { + let req, res; + + beforeEach(() => { + jest.clearAllMocks(); + isEnabled.mockReturnValue(true); + + req = { + user: { + openidId: 'oid-123', + provider: 'openid', + federatedTokens: { + access_token: 'federated-access-token', + id_token: 'federated-id-token', + }, + }, + headers: { authorization: 'Bearer app-jwt-which-is-id-token' }, + query: { scopes: 'https://graph.microsoft.com/.default' }, + }; + + res = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + }; + + getGraphApiToken.mockResolvedValue({ + access_token: 'graph-access-token', + token_type: 'Bearer', + expires_in: 3600, + }); + }); + + it('should pass federatedTokens.access_token as OBO assertion, not the auth header bearer token', async () => { + await graphTokenController(req, res); + + expect(getGraphApiToken).toHaveBeenCalledWith( + req.user, + 'federated-access-token', + 'https://graph.microsoft.com/.default', + ); + expect(getGraphApiToken).not.toHaveBeenCalledWith( + expect.anything(), + 'app-jwt-which-is-id-token', + expect.anything(), + ); + }); + + it('should return the graph token response on success', async () => { + await graphTokenController(req, res); + + expect(res.json).toHaveBeenCalledWith({ + access_token: 'graph-access-token', + token_type: 'Bearer', + expires_in: 3600, + }); + }); + + it('should return 403 when user is not authenticated via Entra ID', async () => { + req.user.provider = 'google'; + req.user.openidId = undefined; + + await graphTokenController(req, res); + + expect(res.status).toHaveBeenCalledWith(403); + expect(getGraphApiToken).not.toHaveBeenCalled(); + }); + + it('should return 403 when OPENID_REUSE_TOKENS is not enabled', async () => { + isEnabled.mockReturnValue(false); + + await graphTokenController(req, res); + + expect(res.status).toHaveBeenCalledWith(403); + expect(getGraphApiToken).not.toHaveBeenCalled(); + }); + + it('should return 400 when scopes query param is missing', async () => { + req.query.scopes = undefined; + + await graphTokenController(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(getGraphApiToken).not.toHaveBeenCalled(); + }); + + it('should return 401 when federatedTokens.access_token is missing', async () => { + req.user.federatedTokens = {}; + + await graphTokenController(req, res); + + expect(res.status).toHaveBeenCalledWith(401); + expect(getGraphApiToken).not.toHaveBeenCalled(); + }); + + it('should return 401 when federatedTokens is absent entirely', async () => { + req.user.federatedTokens = undefined; + + await graphTokenController(req, res); + + expect(res.status).toHaveBeenCalledWith(401); + expect(getGraphApiToken).not.toHaveBeenCalled(); + }); + + it('should return 500 when getGraphApiToken throws', async () => { + getGraphApiToken.mockRejectedValue(new Error('OBO exchange failed')); + + await graphTokenController(req, res); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ + message: 'Failed to obtain Microsoft Graph token', + }); + }); +}); diff --git a/api/server/services/GraphTokenService.js b/api/server/services/GraphTokenService.js index d5cd6a94f2..843adbe5a2 100644 --- a/api/server/services/GraphTokenService.js +++ b/api/server/services/GraphTokenService.js @@ -7,7 +7,7 @@ const getLogStores = require('~/cache/getLogStores'); /** * 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} accessToken - Federated access token used as OBO assertion * @param {string} scopes - Graph API scopes for the token * @param {boolean} fromCache - Whether to try getting token from cache first * @returns {Promise} Graph API token response with access_token and expires_in