Skip to content

Commit cca9d63

Browse files
authored
🔒 refactor: graphTokenController to use federated access token for OBO assertion (danny-avila#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.
1 parent 4404319 commit cca9d63

3 files changed

Lines changed: 152 additions & 11 deletions

File tree

‎api/server/controllers/AuthController.js‎

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -196,23 +196,20 @@ const graphTokenController = async (req, res) => {
196196
});
197197
}
198198

199-
// Extract access token from Authorization header
200-
const authHeader = req.headers.authorization;
201-
if (!authHeader || !authHeader.startsWith('Bearer ')) {
202-
return res.status(401).json({
203-
message: 'Valid authorization token required',
204-
});
205-
}
206-
207-
// Get scopes from query parameters
208199
const scopes = req.query.scopes;
209200
if (!scopes) {
210201
return res.status(400).json({
211202
message: 'Graph API scopes are required as query parameter',
212203
});
213204
}
214205

215-
const accessToken = authHeader.substring(7); // Remove 'Bearer ' prefix
206+
const accessToken = req.user.federatedTokens?.access_token;
207+
if (!accessToken) {
208+
return res.status(401).json({
209+
message: 'No federated access token available for token exchange',
210+
});
211+
}
212+
216213
const tokenResponse = await getGraphApiToken(req.user, accessToken, scopes);
217214

218215
res.json(tokenResponse);
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
jest.mock('@librechat/data-schemas', () => ({
2+
logger: { error: jest.fn(), debug: jest.fn(), warn: jest.fn() },
3+
}));
4+
jest.mock('~/server/services/GraphTokenService', () => ({
5+
getGraphApiToken: jest.fn(),
6+
}));
7+
jest.mock('~/server/services/AuthService', () => ({
8+
requestPasswordReset: jest.fn(),
9+
setOpenIDAuthTokens: jest.fn(),
10+
resetPassword: jest.fn(),
11+
setAuthTokens: jest.fn(),
12+
registerUser: jest.fn(),
13+
}));
14+
jest.mock('~/strategies', () => ({ getOpenIdConfig: jest.fn() }));
15+
jest.mock('~/models', () => ({
16+
deleteAllUserSessions: jest.fn(),
17+
getUserById: jest.fn(),
18+
findSession: jest.fn(),
19+
updateUser: jest.fn(),
20+
findUser: jest.fn(),
21+
}));
22+
jest.mock('@librechat/api', () => ({
23+
isEnabled: jest.fn(),
24+
findOpenIDUser: jest.fn(),
25+
}));
26+
27+
const { isEnabled } = require('@librechat/api');
28+
const { getGraphApiToken } = require('~/server/services/GraphTokenService');
29+
const { graphTokenController } = require('./AuthController');
30+
31+
describe('graphTokenController', () => {
32+
let req, res;
33+
34+
beforeEach(() => {
35+
jest.clearAllMocks();
36+
isEnabled.mockReturnValue(true);
37+
38+
req = {
39+
user: {
40+
openidId: 'oid-123',
41+
provider: 'openid',
42+
federatedTokens: {
43+
access_token: 'federated-access-token',
44+
id_token: 'federated-id-token',
45+
},
46+
},
47+
headers: { authorization: 'Bearer app-jwt-which-is-id-token' },
48+
query: { scopes: 'https://graph.microsoft.com/.default' },
49+
};
50+
51+
res = {
52+
status: jest.fn().mockReturnThis(),
53+
json: jest.fn(),
54+
};
55+
56+
getGraphApiToken.mockResolvedValue({
57+
access_token: 'graph-access-token',
58+
token_type: 'Bearer',
59+
expires_in: 3600,
60+
});
61+
});
62+
63+
it('should pass federatedTokens.access_token as OBO assertion, not the auth header bearer token', async () => {
64+
await graphTokenController(req, res);
65+
66+
expect(getGraphApiToken).toHaveBeenCalledWith(
67+
req.user,
68+
'federated-access-token',
69+
'https://graph.microsoft.com/.default',
70+
);
71+
expect(getGraphApiToken).not.toHaveBeenCalledWith(
72+
expect.anything(),
73+
'app-jwt-which-is-id-token',
74+
expect.anything(),
75+
);
76+
});
77+
78+
it('should return the graph token response on success', async () => {
79+
await graphTokenController(req, res);
80+
81+
expect(res.json).toHaveBeenCalledWith({
82+
access_token: 'graph-access-token',
83+
token_type: 'Bearer',
84+
expires_in: 3600,
85+
});
86+
});
87+
88+
it('should return 403 when user is not authenticated via Entra ID', async () => {
89+
req.user.provider = 'google';
90+
req.user.openidId = undefined;
91+
92+
await graphTokenController(req, res);
93+
94+
expect(res.status).toHaveBeenCalledWith(403);
95+
expect(getGraphApiToken).not.toHaveBeenCalled();
96+
});
97+
98+
it('should return 403 when OPENID_REUSE_TOKENS is not enabled', async () => {
99+
isEnabled.mockReturnValue(false);
100+
101+
await graphTokenController(req, res);
102+
103+
expect(res.status).toHaveBeenCalledWith(403);
104+
expect(getGraphApiToken).not.toHaveBeenCalled();
105+
});
106+
107+
it('should return 400 when scopes query param is missing', async () => {
108+
req.query.scopes = undefined;
109+
110+
await graphTokenController(req, res);
111+
112+
expect(res.status).toHaveBeenCalledWith(400);
113+
expect(getGraphApiToken).not.toHaveBeenCalled();
114+
});
115+
116+
it('should return 401 when federatedTokens.access_token is missing', async () => {
117+
req.user.federatedTokens = {};
118+
119+
await graphTokenController(req, res);
120+
121+
expect(res.status).toHaveBeenCalledWith(401);
122+
expect(getGraphApiToken).not.toHaveBeenCalled();
123+
});
124+
125+
it('should return 401 when federatedTokens is absent entirely', async () => {
126+
req.user.federatedTokens = undefined;
127+
128+
await graphTokenController(req, res);
129+
130+
expect(res.status).toHaveBeenCalledWith(401);
131+
expect(getGraphApiToken).not.toHaveBeenCalled();
132+
});
133+
134+
it('should return 500 when getGraphApiToken throws', async () => {
135+
getGraphApiToken.mockRejectedValue(new Error('OBO exchange failed'));
136+
137+
await graphTokenController(req, res);
138+
139+
expect(res.status).toHaveBeenCalledWith(500);
140+
expect(res.json).toHaveBeenCalledWith({
141+
message: 'Failed to obtain Microsoft Graph token',
142+
});
143+
});
144+
});

‎api/server/services/GraphTokenService.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ const getLogStores = require('~/cache/getLogStores');
77
/**
88
* Get Microsoft Graph API token using existing token exchange mechanism
99
* @param {Object} user - User object with OpenID information
10-
* @param {string} accessToken - Current access token from Authorization header
10+
* @param {string} accessToken - Federated access token used as OBO assertion
1111
* @param {string} scopes - Graph API scopes for the token
1212
* @param {boolean} fromCache - Whether to try getting token from cache first
1313
* @returns {Promise<Object>} Graph API token response with access_token and expires_in

0 commit comments

Comments
 (0)