mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-02-22 18:34:08 +01:00
🔒 refactor: graphTokenController to use federated access token for OBO assertion (#11893)
- 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.
This commit is contained in:
parent
4404319e22
commit
cca9d63224
3 changed files with 152 additions and 11 deletions
|
|
@ -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);
|
||||
|
|
|
|||
144
api/server/controllers/AuthController.spec.js
Normal file
144
api/server/controllers/AuthController.spec.js
Normal file
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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<Object>} Graph API token response with access_token and expires_in
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue