Skip to content

Commit cdb2e72

Browse files
committed
fix: search_work_items authentication with Azure Identity
1 parent bbc2e79 commit cdb2e72

File tree

7 files changed

+186
-33
lines changed

7 files changed

+186
-33
lines changed

project-management/task-management/done.md

+16
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,21 @@
11
## Completed Tasks
22

3+
- [x] **Task**: Fix search_work_items API authentication error with Azure Identity (GitHub Issue #120)
4+
- **Role**: Full-Stack Developer
5+
- **Phase**: Completed
6+
- **Notes**:
7+
- Fixed an authentication issue with the search_work_items API when using Azure Identity authentication
8+
- The search API was returning HTML for a login page instead of JSON data
9+
- Identified that the search API endpoints (almsearch.dev.azure.com) require Bearer token authentication when using Azure Identity
10+
- Fixed all three search features (work items, code, wiki) to properly handle Azure Identity authentication
11+
- Added unit and integration tests to verify the fix
12+
- **Sub-tasks**:
13+
- [x] Updated the getAuthorizationHeader function in search-work-items feature to properly handle Azure Identity authentication
14+
- [x] Applied the same fix to search-code and search-wiki features for consistency
15+
- [x] Added unit tests to verify Azure Identity token acquisition
16+
- [x] Added integration tests for the Azure Identity authentication path
17+
- **Completed**: April 2, 2025
18+
319
- [x] **Task**: Migrate release automation to use Release Please for version management (GitHub Issue #113)
420
- **Role**: DevOps Engineer
521
- **Phase**: Completed

src/features/search/search-code/feature.spec.unit.ts

+13
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,19 @@ import { searchCode } from './feature';
33
import { WebApi } from 'azure-devops-node-api';
44
import { AzureDevOpsError } from '../../../shared/errors';
55

6+
// Mock Azure Identity
7+
jest.mock('@azure/identity', () => {
8+
const mockGetToken = jest.fn().mockResolvedValue({ token: 'mock-token' });
9+
return {
10+
DefaultAzureCredential: jest.fn().mockImplementation(() => ({
11+
getToken: mockGetToken,
12+
})),
13+
AzureCliCredential: jest.fn().mockImplementation(() => ({
14+
getToken: mockGetToken,
15+
})),
16+
};
17+
});
18+
619
// Mock axios
720
jest.mock('axios');
821
const mockedAxios = axios as jest.Mocked<typeof axios>;

src/features/search/search-code/feature.ts

+23-11
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { WebApi } from 'azure-devops-node-api';
22
import axios from 'axios';
3+
import { DefaultAzureCredential, AzureCliCredential } from '@azure/identity';
34
import {
45
AzureDevOpsError,
56
AzureDevOpsResourceNotFoundError,
@@ -39,7 +40,7 @@ export async function searchCode(
3940
};
4041

4142
// Get the authorization header from the connection
42-
const authHeader = await getAuthorizationHeader(connection);
43+
const authHeader = await getAuthorizationHeader();
4344

4445
// Extract organization and project from the connection URL
4546
const { organization, project } = extractOrgAndProject(
@@ -134,10 +135,9 @@ function extractOrgAndProject(
134135
/**
135136
* Get the authorization header from the connection
136137
*
137-
* @param connection The Azure DevOps WebApi connection
138138
* @returns The authorization header
139139
*/
140-
async function getAuthorizationHeader(connection: WebApi): Promise<string> {
140+
async function getAuthorizationHeader(): Promise<string> {
141141
try {
142142
// For PAT authentication, we can construct the header directly
143143
if (
@@ -150,15 +150,27 @@ async function getAuthorizationHeader(connection: WebApi): Promise<string> {
150150
return `Basic ${base64Token}`;
151151
}
152152

153-
// For other auth methods, we'll make a simple API call to get a valid token
154-
// This is a workaround since we can't directly access the auth handler's token
155-
const coreApi = await connection.getCoreApi();
156-
await coreApi.getProjects();
153+
// For Azure Identity / Azure CLI auth, we need to get a token
154+
// using the Azure DevOps resource ID
155+
// Choose the appropriate credential based on auth method
156+
const credential =
157+
process.env.AZURE_DEVOPS_AUTH_METHOD?.toLowerCase() === 'azure-cli'
158+
? new AzureCliCredential()
159+
: new DefaultAzureCredential();
160+
161+
// Azure DevOps resource ID for token acquisition
162+
const AZURE_DEVOPS_RESOURCE_ID = '499b84ac-1321-427f-aa17-267ca6975798';
163+
164+
// Get token for Azure DevOps
165+
const token = await credential.getToken(
166+
`${AZURE_DEVOPS_RESOURCE_ID}/.default`,
167+
);
168+
169+
if (!token || !token.token) {
170+
throw new Error('Failed to acquire token for Azure DevOps');
171+
}
157172

158-
// At this point, the connection should have made a request and we can
159-
// extract the auth header from the most recent request
160-
// If this fails, we'll fall back to a default approach
161-
return `Basic ${Buffer.from(':' + process.env.AZURE_DEVOPS_PAT).toString('base64')}`;
173+
return `Bearer ${token.token}`;
162174
} catch (error) {
163175
throw new AzureDevOpsValidationError(
164176
`Failed to get authorization header: ${error instanceof Error ? error.message : String(error)}`,

src/features/search/search-wiki/feature.ts

+23-11
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { WebApi } from 'azure-devops-node-api';
22
import axios from 'axios';
3+
import { DefaultAzureCredential, AzureCliCredential } from '@azure/identity';
34
import {
45
AzureDevOpsError,
56
AzureDevOpsResourceNotFoundError,
@@ -50,7 +51,7 @@ export async function searchWiki(
5051
}
5152

5253
// Get the authorization header from the connection
53-
const authHeader = await getAuthorizationHeader(connection);
54+
const authHeader = await getAuthorizationHeader();
5455

5556
// Extract organization and project from the connection URL
5657
const { organization, project } = extractOrgAndProject(
@@ -141,10 +142,9 @@ function extractOrgAndProject(
141142
/**
142143
* Get the authorization header from the connection
143144
*
144-
* @param connection The Azure DevOps WebApi connection
145145
* @returns The authorization header
146146
*/
147-
async function getAuthorizationHeader(connection: WebApi): Promise<string> {
147+
async function getAuthorizationHeader(): Promise<string> {
148148
try {
149149
// For PAT authentication, we can construct the header directly
150150
if (
@@ -157,15 +157,27 @@ async function getAuthorizationHeader(connection: WebApi): Promise<string> {
157157
return `Basic ${base64Token}`;
158158
}
159159

160-
// For other auth methods, we'll make a simple API call to get a valid token
161-
// This is a workaround since we can't directly access the auth handler's token
162-
const coreApi = await connection.getCoreApi();
163-
await coreApi.getProjects();
160+
// For Azure Identity / Azure CLI auth, we need to get a token
161+
// using the Azure DevOps resource ID
162+
// Choose the appropriate credential based on auth method
163+
const credential =
164+
process.env.AZURE_DEVOPS_AUTH_METHOD?.toLowerCase() === 'azure-cli'
165+
? new AzureCliCredential()
166+
: new DefaultAzureCredential();
167+
168+
// Azure DevOps resource ID for token acquisition
169+
const AZURE_DEVOPS_RESOURCE_ID = '499b84ac-1321-427f-aa17-267ca6975798';
170+
171+
// Get token for Azure DevOps
172+
const token = await credential.getToken(
173+
`${AZURE_DEVOPS_RESOURCE_ID}/.default`,
174+
);
175+
176+
if (!token || !token.token) {
177+
throw new Error('Failed to acquire token for Azure DevOps');
178+
}
164179

165-
// At this point, the connection should have made a request and we can
166-
// extract the auth header from the most recent request
167-
// If this fails, we'll fall back to a default approach
168-
return `Basic ${Buffer.from(':' + process.env.AZURE_DEVOPS_PAT).toString('base64')}`;
180+
return `Bearer ${token.token}`;
169181
} catch (error) {
170182
throw new AzureDevOpsValidationError(
171183
`Failed to get authorization header: ${error instanceof Error ? error.message : String(error)}`,

src/features/search/search-work-items/feature.spec.int.ts

+34
Original file line numberDiff line numberDiff line change
@@ -169,4 +169,38 @@ describeOrSkip('searchWorkItems (Integration)', () => {
169169
}
170170
}
171171
}, 30000);
172+
173+
// Add a test to verify Azure Identity authentication if configured
174+
if (
175+
process.env.AZURE_DEVOPS_AUTH_METHOD?.toLowerCase() === 'azure-identity'
176+
) {
177+
test('should search work items using Azure Identity authentication', async () => {
178+
// Skip if required environment variables are missing
179+
if (!process.env.AZURE_DEVOPS_ORG_URL || !process.env.TEST_PROJECT_ID) {
180+
console.log('Skipping test: required environment variables missing');
181+
return;
182+
}
183+
184+
// Create a config with Azure Identity authentication
185+
const testConfig: AzureDevOpsConfig = {
186+
organizationUrl: process.env.AZURE_DEVOPS_ORG_URL,
187+
authMethod: AuthenticationMethod.AzureIdentity,
188+
defaultProject: process.env.TEST_PROJECT_ID,
189+
};
190+
191+
// Create the connection using the config
192+
const connection = await getConnection(testConfig);
193+
194+
// Search work items
195+
const result = await searchWorkItems(connection, {
196+
projectId: process.env.TEST_PROJECT_ID,
197+
searchText: 'test',
198+
});
199+
200+
// Check that the response is properly formatted
201+
expect(result).toBeDefined();
202+
expect(result.count).toBeDefined();
203+
expect(Array.isArray(result.results)).toBe(true);
204+
});
205+
}
172206
});

src/features/search/search-work-items/feature.spec.unit.ts

+54
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,16 @@ import { SearchWorkItemsOptions, WorkItemSearchResponse } from '../types';
1313
jest.mock('axios');
1414
const mockedAxios = axios as jest.Mocked<typeof axios>;
1515

16+
// Mock @azure/identity
17+
jest.mock('@azure/identity', () => ({
18+
DefaultAzureCredential: jest.fn().mockImplementation(() => ({
19+
getToken: jest
20+
.fn()
21+
.mockResolvedValue({ token: 'mock-azure-identity-token' }),
22+
})),
23+
AzureCliCredential: jest.fn(),
24+
}));
25+
1626
// Mock WebApi
1727
jest.mock('azure-devops-node-api');
1828
const MockedWebApi = WebApi as jest.MockedClass<typeof WebApi>;
@@ -266,4 +276,48 @@ describe('searchWorkItems', () => {
266276
AzureDevOpsValidationError,
267277
);
268278
});
279+
280+
it('should use Azure Identity authentication when AZURE_DEVOPS_AUTH_METHOD is azure-identity', async () => {
281+
// Mock environment variables
282+
const originalEnv = process.env.AZURE_DEVOPS_AUTH_METHOD;
283+
process.env.AZURE_DEVOPS_AUTH_METHOD = 'azure-identity';
284+
285+
// Mock the WebApi connection
286+
const mockConnection = {
287+
serverUrl: 'https://dev.azure.com/testorg',
288+
getCoreApi: jest.fn().mockResolvedValue({
289+
getProjects: jest.fn().mockResolvedValue([]),
290+
}),
291+
};
292+
293+
// Mock axios post
294+
const mockResponse = {
295+
data: {
296+
count: 0,
297+
results: [],
298+
},
299+
};
300+
(axios.post as jest.Mock).mockResolvedValueOnce(mockResponse);
301+
302+
// Call the function
303+
await searchWorkItems(mockConnection as unknown as WebApi, {
304+
projectId: 'testproject',
305+
searchText: 'test query',
306+
});
307+
308+
// Verify the axios post was called with a Bearer token
309+
expect(axios.post).toHaveBeenCalledWith(
310+
expect.any(String),
311+
expect.any(Object),
312+
{
313+
headers: {
314+
Authorization: 'Bearer mock-azure-identity-token',
315+
'Content-Type': 'application/json',
316+
},
317+
},
318+
);
319+
320+
// Cleanup
321+
process.env.AZURE_DEVOPS_AUTH_METHOD = originalEnv;
322+
});
269323
});

src/features/search/search-work-items/feature.ts

+23-11
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { WebApi } from 'azure-devops-node-api';
22
import axios from 'axios';
3+
import { DefaultAzureCredential, AzureCliCredential } from '@azure/identity';
34
import {
45
AzureDevOpsError,
56
AzureDevOpsResourceNotFoundError,
@@ -38,7 +39,7 @@ export async function searchWorkItems(
3839
};
3940

4041
// Get the authorization header from the connection
41-
const authHeader = await getAuthorizationHeader(connection);
42+
const authHeader = await getAuthorizationHeader();
4243

4344
// Extract organization and project from the connection URL
4445
const { organization, project } = extractOrgAndProject(
@@ -127,10 +128,9 @@ function extractOrgAndProject(
127128
/**
128129
* Get the authorization header from the connection
129130
*
130-
* @param connection The Azure DevOps WebApi connection
131131
* @returns The authorization header
132132
*/
133-
async function getAuthorizationHeader(connection: WebApi): Promise<string> {
133+
async function getAuthorizationHeader(): Promise<string> {
134134
try {
135135
// For PAT authentication, we can construct the header directly
136136
if (
@@ -143,15 +143,27 @@ async function getAuthorizationHeader(connection: WebApi): Promise<string> {
143143
return `Basic ${base64Token}`;
144144
}
145145

146-
// For other auth methods, we'll make a simple API call to get a valid token
147-
// This is a workaround since we can't directly access the auth handler's token
148-
const coreApi = await connection.getCoreApi();
149-
await coreApi.getProjects();
146+
// For Azure Identity / Azure CLI auth, we need to get a token
147+
// using the Azure DevOps resource ID
148+
// Choose the appropriate credential based on auth method
149+
const credential =
150+
process.env.AZURE_DEVOPS_AUTH_METHOD?.toLowerCase() === 'azure-cli'
151+
? new AzureCliCredential()
152+
: new DefaultAzureCredential();
153+
154+
// Azure DevOps resource ID for token acquisition
155+
const AZURE_DEVOPS_RESOURCE_ID = '499b84ac-1321-427f-aa17-267ca6975798';
156+
157+
// Get token for Azure DevOps
158+
const token = await credential.getToken(
159+
`${AZURE_DEVOPS_RESOURCE_ID}/.default`,
160+
);
161+
162+
if (!token || !token.token) {
163+
throw new Error('Failed to acquire token for Azure DevOps');
164+
}
150165

151-
// At this point, the connection should have made a request and we can
152-
// extract the auth header from the most recent request
153-
// If this fails, we'll fall back to a default approach
154-
return `Basic ${Buffer.from(':' + process.env.AZURE_DEVOPS_PAT).toString('base64')}`;
166+
return `Bearer ${token.token}`;
155167
} catch (error) {
156168
throw new AzureDevOpsValidationError(
157169
`Failed to get authorization header: ${error instanceof Error ? error.message : String(error)}`,

0 commit comments

Comments
 (0)