From 4767c9f7791bc11a77608be72cc09460fb342169 Mon Sep 17 00:00:00 2001 From: Gabriel Castro Date: Fri, 8 May 2026 16:35:51 -0700 Subject: [PATCH 1/2] Add Entra auth using az cli --- README.md | 17 ++- app/exec/login.ts | 37 +++++-- app/lib/entra.ts | 110 ++++++++++++++++++ app/lib/errorhandler.ts | 2 +- app/lib/tfcommand.ts | 141 +++++++++++++++--------- tests/README-server-integration.md | 6 +- tests/build-server-integration-tests.ts | 2 +- tests/errorhandler-tests.ts | 4 +- tests/mock-server/MockDevOpsServer.ts | 4 +- tests/mock-server/utils/Logger.ts | 2 +- tests/server-integration-login.ts | 77 ++++++++++++- tests/test-utils/debug-exec.ts | 35 ++++-- 12 files changed, 357 insertions(+), 80 deletions(-) create mode 100644 app/lib/entra.ts diff --git a/README.md b/README.md index d64c9d4a..d1803b8b 100644 --- a/README.md +++ b/README.md @@ -47,11 +47,11 @@ tfx --help ### Login -To avoid providing credentials with every command, you can login once. Currently supported credential types: _Personal Access Tokens_ and _basic authentication credentials_. +To avoid providing credentials with every command, you can login once. Currently supported credential types: _Personal Access Tokens_, _basic authentication credentials_, and _Microsoft Entra_ bearer tokens. > NTLM support is under consideration > -> Warning! Using this feature will store your login credentials on disk in plain text. +> Warning! Using this feature will store your login credentials on disk in plain text for PAT and basic auth. Microsoft Entra login stores only the cached auth strategy. > > To skip certificate validation connecting to on-prem _Azure DevOps Server_ use the `--skip-cert-validation` parameter. @@ -77,6 +77,19 @@ Examples of valid URLs are: You can also use basic authentication by passing the `--auth-type basic` parameter (see [Configuring Basic Auth](docs/configureBasicAuth.md) for details). +#### Microsoft Entra + +For Azure DevOps Services, you can cache a Microsoft Entra-backed login by signing in with Azure CLI first and then using `--auth-type entra`. + +```bash +az login +tfx login --service-url https://dev.azure.com/yourorg --auth-type entra +``` + +For automation scenarios, the same flow works after `az login --service-principal ...` or `az login --identity`. + +If you already have a bearer token, set `TFX_ENTRA_TOKEN` or `AZURE_DEVOPS_ENTRA_TOKEN` before running `tfx login --auth-type entra`. + ### Settings cache To avoid providing options with every command, you can save them to a settings file by adding the `--save` flag. diff --git a/app/exec/login.ts b/app/exec/login.ts index eac043ee..c56a22a7 100644 --- a/app/exec/login.ts +++ b/app/exec/login.ts @@ -18,9 +18,37 @@ export interface LoginResult { * Facilitates a "log in" to a service by caching credentials. */ export class Login extends TfCommand { - protected description = "Login and cache credentials using a PAT or basic auth."; + protected description = "Login and cache credentials using a PAT, basic auth, or Microsoft Entra auth."; protected serverCommand = true; + private async getCredentialCacheValue(authHandler: any): Promise { + const [authType, token, username, password] = await Promise.all([ + this.commandArgs.authType.val(), + this.commandArgs.token.val(true), + this.commandArgs.username.val(true), + this.commandArgs.password.val(true), + ]); + const normalizedAuthType = (authType || "pat").toLowerCase(); + + if (username && password) { + return "basic:" + username + ":" + password; + } + + if (token) { + return "pat:" + token; + } + + if (normalizedAuthType === "entra") { + return "entra"; + } + + if (normalizedAuthType === "basic") { + return "basic:" + authHandler.username + ":" + authHandler.password; + } + + return "pat:" + authHandler.password; + } + public async exec(): Promise { trace.debug("Login.exec"); return this.commandArgs.serviceUrl.val().then(async collectionUrl => { @@ -36,12 +64,7 @@ export class Login extends TfCommand { const connectionData = await locationsApi.getConnectionData(); let tfxCredStore = getCredentialStore("tfx"); let tfxCache = new DiskCache("tfx"); - let credString; - if (authHandler.username === "OAuth") { - credString = "pat:" + authHandler.password; - } else { - credString = "basic:" + authHandler.username + ":" + authHandler.password; - } + const credString = await this.getCredentialCacheValue(authHandler); await tfxCredStore.storeCredential(collectionUrl, "allusers", credString); await tfxCache.setItem("cache", "connection", collectionUrl); await tfxCache.setItem("cache", "skipCertValidation", skipCertValidation.toString()); diff --git a/app/lib/entra.ts b/app/lib/entra.ts new file mode 100644 index 00000000..dfa81f66 --- /dev/null +++ b/app/lib/entra.ts @@ -0,0 +1,110 @@ +import { exec } from "child_process"; +import { promisify } from "util"; +import trace = require("./trace"); + +const execAsync = promisify(exec); + +const AZURE_DEVOPS_SCOPE = "https://app.vssps.visualstudio.com/.default"; +const AZURE_DEVOPS_RESOURCE = "499b84ac-1321-427f-aa17-267ca6975798"; +const ENTRA_TOKEN_ENV_VARS = ["TFX_ENTRA_TOKEN", "AZURE_DEVOPS_ENTRA_TOKEN"]; + +function isAzureCliMissing(err: any): boolean { + const errorText = [err && err.stderr, err && err.message] + .filter(message => !!message) + .map(message => String(message)) + .join("\n") + .toLowerCase(); + + if (err && err.code === "ENOENT") { + return true; + } + + return ( + errorText.includes("'az' is not recognized as an internal or external command") || + errorText.includes('"az" is not recognized as an internal or external command') || + errorText.includes("the term 'az' is not recognized") || + errorText.includes("az: not found") || + errorText.includes("az: command not found") || + (errorText.includes("az account get-access-token") && errorText.includes("operable program or batch file")) + ); +} + +function getAzureCliMissingError(): Error { + return new Error( + "Azure CLI (az) is required for Microsoft Entra authentication. Install Azure CLI and run 'az login' (or 'az login --service-principal' / 'az login --identity'), or set TFX_ENTRA_TOKEN or AZURE_DEVOPS_ENTRA_TOKEN.", + ); +} + +function getEntraTokenFromEnvironment(): string { + for (const envVar of ENTRA_TOKEN_ENV_VARS) { + const token = process.env[envVar]; + if (token && token.trim()) { + trace.debug(`Using Microsoft Entra token from ${envVar}.`); + return token.trim(); + } + } + + return null; +} + +async function getTokenFromAzureCli(command: string): Promise { + trace.debug(`Acquiring Microsoft Entra token with Azure CLI: ${command}`); + const result = await execAsync(command); + const token = result.stdout && result.stdout.trim(); + + if (!token) { + throw new Error("Azure CLI returned an empty access token."); + } + + return token; +} + +function getAzureCliErrorMessage(err: any): string { + if (err && err.stderr && String(err.stderr).trim()) { + return String(err.stderr).trim(); + } + + if (err && err.message && String(err.message).trim()) { + return String(err.message).trim(); + } + + return null; +} + +export async function getEntraAccessToken(): Promise { + const envToken = getEntraTokenFromEnvironment(); + if (envToken) { + return envToken; + } + + const scopeCommand = `az account get-access-token --only-show-errors --scope "${AZURE_DEVOPS_SCOPE}" --query accessToken -o tsv`; + try { + return await getTokenFromAzureCli(scopeCommand); + } catch (scopeError) { + if (isAzureCliMissing(scopeError)) { + throw getAzureCliMissingError(); + } + + const resourceCommand = `az account get-access-token --only-show-errors --resource ${AZURE_DEVOPS_RESOURCE} --query accessToken -o tsv`; + try { + return await getTokenFromAzureCli(resourceCommand); + } catch (resourceError) { + if (isAzureCliMissing(resourceError)) { + throw getAzureCliMissingError(); + } + + const details = [getAzureCliErrorMessage(scopeError), getAzureCliErrorMessage(resourceError)] + .filter(message => !!message) + .filter((message, index, messages) => messages.indexOf(message) === index); + + let message = + "Unable to acquire a Microsoft Entra access token. Run 'az login' (or 'az login --service-principal' / 'az login --identity') first, or set TFX_ENTRA_TOKEN or AZURE_DEVOPS_ENTRA_TOKEN."; + + if (details.length > 0) { + message += " Azure CLI output: " + details.join(" "); + } + + throw new Error(message); + } + } +} \ No newline at end of file diff --git a/app/lib/errorhandler.ts b/app/lib/errorhandler.ts index d0c37812..c9075a3c 100644 --- a/app/lib/errorhandler.ts +++ b/app/lib/errorhandler.ts @@ -61,7 +61,7 @@ export function httpErr(obj): any { } let statusCode: number = errorAsObj.statusCode; if (statusCode === 401) { - throw "Received response 401 (Not Authorized). Check that your personal access token is correct and hasn't expired."; + throw "Received response 401 (Not Authorized). Check that your credentials are correct and that any access token hasn't expired."; } if (statusCode === 403) { throw "Received response 403 (Forbidden). Check that you have access to this resource. Message from server: " + diff --git a/app/lib/tfcommand.ts b/app/lib/tfcommand.ts index af50488c..b55f5124 100644 --- a/app/lib/tfcommand.ts +++ b/app/lib/tfcommand.ts @@ -1,9 +1,9 @@ -import { BasicCredentialHandler } from "azure-devops-node-api/handlers/basiccreds"; import { DiskCache } from "../lib/diskcache"; import { getCredentialStore } from "../lib/credstore"; +import { getEntraAccessToken } from "../lib/entra"; import { repeatStr } from "../lib/common"; import { TfsConnection } from "../lib/connection"; -import { WebApi, getBasicHandler } from "azure-devops-node-api/WebApi"; +import { WebApi, getBasicHandler, getBearerHandler } from "azure-devops-node-api/WebApi"; import { EOL as eol } from "os"; import _ = require("lodash"); import args = require("./arguments"); @@ -17,7 +17,7 @@ import fsUtils = require("./fsUtils"); import { promisify } from "util"; import trace = require("./trace"); import version = require("./version"); -import { IRequestOptions } from "azure-devops-node-api/interfaces/common/VsoBaseInterfaces"; +import { IRequestHandler, IRequestOptions } from "azure-devops-node-api/interfaces/common/VsoBaseInterfaces"; const clipboardyWrite = (data: string) => import('clipboardy').then(clipboardy => clipboardy.default.writeSync(data)); export interface CoreArguments { @@ -298,7 +298,7 @@ export abstract class TfCommand { this.registerCommandArgument( ["authType"], "Authentication Method", - "Method of authentication ('pat' or 'basic').", + "Method of authentication ('pat', 'basic', or 'entra').", args.StringArgument, "pat", ); @@ -314,7 +314,7 @@ export abstract class TfCommand { "Password to use for basic authentication.", args.SilentStringArgument, ); - this.registerCommandArgument(["token", "-t"], "Personal access token", null, args.SilentStringArgument); + this.registerCommandArgument(["token", "-t"], "Personal access token", "Personal access token to use for PAT authentication.", args.SilentStringArgument); this.registerCommandArgument( ["save"], "Save settings", @@ -403,63 +403,98 @@ export abstract class TfCommand { * If token is passed in, use that. * Else, check the authType - if it is "pat", prompt for a token * If it is "basic", prompt for username and password. + * If it is "entra", resolve a Microsoft Entra bearer token from the environment or Azure CLI. */ - protected getCredentials(serviceUrl: string, useCredStore: boolean = true): Promise { + private async getEntraHandler(): Promise { + const accessToken = await getEntraAccessToken(); + return getBearerHandler(accessToken); + } + + private async getStoredCredentialHandler(credString: string): Promise { + if (!credString || credString.length <= 4) { + throw new Error("Could not get credentials from credential store."); + } + + if (credString.substr(0, 3) === "pat") { + return getBasicHandler("OAuth", credString.substr(4)); + } else if (credString.substr(0, 5) === "basic") { + const rest = credString.substr(6); + const unpwDividerIndex = rest.indexOf(":"); + const username = rest.substr(0, unpwDividerIndex); + const password = rest.substr(unpwDividerIndex + 1); + if (username && password) { + return getBasicHandler(username, password); + } + } else if (credString === "entra" || credString.substr(0, 5) === "entra") { + return this.getEntraHandler(); + } + + throw new Error("Could not get credentials from credential store."); + } + + private async getConfiguredCredentialHandler( + authType: string, + token?: string, + username?: string, + password?: string, + ): Promise { + const normalizedAuthType = (authType || "pat").toLowerCase(); + + if (normalizedAuthType === "entra") { + if (token || username || password) { + throw new Error( + "Auth type 'entra' uses a Microsoft Entra token from Azure CLI or the TFX_ENTRA_TOKEN/AZURE_DEVOPS_ENTRA_TOKEN environment variables. Do not pass --token, --username, or --password.", + ); + } + + return this.getEntraHandler(); + } + + if (username && password) { + return getBasicHandler(username, password); + } + + if (token) { + return getBasicHandler("OAuth", token); + } + + if (normalizedAuthType === "pat") { + const patToken = await this.commandArgs.token.val(); + return getBasicHandler("OAuth", patToken); + } else if (normalizedAuthType === "basic") { + const basicUsername = await this.commandArgs.username.val(); + const basicPassword = await this.commandArgs.password.val(); + return getBasicHandler(basicUsername, basicPassword); + } + + throw new Error("Unsupported auth type. Currently, 'pat', 'basic', and 'entra' auth are supported."); + } + + protected async getCredentials(serviceUrl: string, useCredStore: boolean = true): Promise { return Promise.all([ this.commandArgs.authType.val(), this.commandArgs.token.val(true), this.commandArgs.username.val(true), this.commandArgs.password.val(true), - ]).then(values => { + ]).then(async values => { const [authType, token, username, password] = values; - if (username && password) { - return getBasicHandler(username, password); - } else { - if (token) { - return getBasicHandler("OAuth", token); - } else { - let getCredentialPromise; - if (useCredStore) { - getCredentialPromise = getCredentialStore("tfx").getCredential(serviceUrl, "allusers"); - } else { - getCredentialPromise = Promise.reject("not using cred store."); - } - return getCredentialPromise - .then((credString: string) => { - if (credString.length <= 6) { - throw "Could not get credentials from credential store."; - } - if (credString.substr(0, 3) === "pat") { - return getBasicHandler("OAuth", credString.substr(4)); - } else if (credString.substr(0, 5) === "basic") { - let rest = credString.substr(6); - let unpwDividerIndex = rest.indexOf(":"); - let username = rest.substr(0, unpwDividerIndex); - let password = rest.substr(unpwDividerIndex + 1); - if (username && password) { - return getBasicHandler(username, password); - } else { - throw "Could not get credentials from credential store."; - } - } - }) - .catch(() => { - if (authType.toLowerCase() === "pat") { - return this.commandArgs.token.val().then(token => { - return getBasicHandler("OAuth", token); - }); - } else if (authType.toLowerCase() === "basic") { - return this.commandArgs.username.val().then(username => { - return this.commandArgs.password.val().then(password => { - return getBasicHandler(username, password); - }); - }); - } else { - throw new Error("Unsupported auth type. Currently, 'pat' and 'basic' auth are supported."); - } - }); + const normalizedAuthType = (authType || "pat").toLowerCase(); + const explicitEntraAuth = normalizedAuthType === "entra" && !this.commandArgs.authType.hasDefaultValue; + + if (explicitEntraAuth || (username && password) || token) { + return this.getConfiguredCredentialHandler(authType, token, username, password); + } + + if (useCredStore) { + try { + const credString = await getCredentialStore("tfx").getCredential(serviceUrl, "allusers"); + return await this.getStoredCredentialHandler(credString); + } catch (err) { + trace.debug("Could not load credentials from credential store."); } } + + return this.getConfiguredCredentialHandler(authType, token, username, password); }); } diff --git a/tests/README-server-integration.md b/tests/README-server-integration.md index 4667a11e..94489388 100644 --- a/tests/README-server-integration.md +++ b/tests/README-server-integration.md @@ -7,7 +7,7 @@ This directory contains integration tests for TFS CLI commands that connect to A The server integration tests are designed to: 1. **Test server connectivity**: Verify that commands properly attempt to connect to servers -2. **Test authentication flows**: Validate basic auth, PAT, and credential caching +2. **Test authentication flows**: Validate basic auth, PAT, Microsoft Entra auth, and credential caching 3. **Test API interactions**: Ensure commands make appropriate API calls 4. **Test error handling**: Verify proper error messages and connection failure handling 5. **Test command-line argument validation**: Ensure required parameters are properly validated @@ -22,13 +22,13 @@ The `MockDevOpsServer` class is integrated into the test suite (`tests/mock-serv - **Work Item APIs**: Create, read, update work items and execute queries - **Extension APIs**: Publish, show, share, install extensions - **Task Agent APIs**: Upload, list, delete build tasks -- **Authentication**: Basic auth and PAT validation +- **Authentication**: Basic auth, PAT, and bearer/Entra validation - **Connection Data**: Mock connection endpoints ### Features - Configurable host and port -- Authentication simulation (basic auth and PAT) +- Authentication simulation (basic auth, PAT, and bearer/Entra) - CORS headers for browser testing - JSON response formatting - Proper HTTP status codes diff --git a/tests/build-server-integration-tests.ts b/tests/build-server-integration-tests.ts index a2521176..7e0191a0 100644 --- a/tests/build-server-integration-tests.ts +++ b/tests/build-server-integration-tests.ts @@ -578,7 +578,7 @@ describe('Build Commands - Server Integration Tests', function() { }) .catch((error) => { const errorOutput = stripColors(error.stderr || error.stdout || ''); - assert(errorOutput.includes("error: Error: Unsupported auth type. Currently, 'pat' and 'basic' auth are supported."), 'Should indicate unsupported auth type'); + assert(errorOutput.includes("error: Error: Unsupported auth type. Currently, 'pat', 'basic', and 'entra' auth are supported."), 'Should indicate unsupported auth type'); done(); }); }); diff --git a/tests/errorhandler-tests.ts b/tests/errorhandler-tests.ts index 4ea0d07f..de06c70b 100644 --- a/tests/errorhandler-tests.ts +++ b/tests/errorhandler-tests.ts @@ -212,7 +212,7 @@ describe('Error Handler Tests', function() { assert.throws( () => errHandler.httpErr(httpError), - /401.*Not Authorized.*personal access token/, + /401.*Not Authorized.*credentials.*access token/, 'should throw descriptive 401 error' ); }); @@ -414,7 +414,7 @@ describe('Share/Unshare error scenarios - Exit code verification', function() { it('should exit with code -1 when HTTP error occurs during share/unshare', function() { // Simulate HTTP 401 error that gets thrown by httpErr - errHandler.errLog('Received response 401 (Not Authorized). Check that your personal access token is correct and hasn\'t expired.'); + errHandler.errLog('Received response 401 (Not Authorized). Check that your credentials are correct and that any access token hasn\'t expired.'); assert(exitCalled, 'process.exit should have been called'); assert.strictEqual(exitCode, -1, 'exit code should be -1 for HTTP errors'); diff --git a/tests/mock-server/MockDevOpsServer.ts b/tests/mock-server/MockDevOpsServer.ts index ac4f4348..3f9f48f5 100644 --- a/tests/mock-server/MockDevOpsServer.ts +++ b/tests/mock-server/MockDevOpsServer.ts @@ -88,8 +88,8 @@ export class MockDevOpsServer { const authHeader = req.headers.authorization; if (!authHeader) return false; - // Simple basic auth check - looking for "Basic " prefix - return authHeader.startsWith('Basic '); + // Accept both Basic and Bearer auth to cover PAT/basic and Microsoft Entra flows. + return authHeader.startsWith('Basic ') || authHeader.startsWith('Bearer '); } // Public API methods diff --git a/tests/mock-server/utils/Logger.ts b/tests/mock-server/utils/Logger.ts index 35476394..7286aeb2 100644 --- a/tests/mock-server/utils/Logger.ts +++ b/tests/mock-server/utils/Logger.ts @@ -36,7 +36,7 @@ export class Logger { return `Basic ${Logger.obscureToken(token)}`; } - // Handle Bearer token (PAT) + // Handle Bearer authentication if (auth.startsWith('Bearer ')) { const token = auth.substring(7); return `Bearer ${Logger.obscureToken(token)}`; diff --git a/tests/server-integration-login.ts b/tests/server-integration-login.ts index 41e380b1..357465e4 100644 --- a/tests/server-integration-login.ts +++ b/tests/server-integration-login.ts @@ -13,6 +13,8 @@ declare function before(fn: Function): void; declare function after(fn: Function): void; const tfxPath = path.resolve(__dirname, '../../_build/tfx-cli.js'); +const testProject = 'TestProject'; +const nodePath = process.execPath; describe('Server Integration Tests - Login and Authentication', function() { let mockServer: MockDevOpsServer; @@ -93,6 +95,54 @@ describe('Server Integration Tests - Login and Authentication', function() { }); }); + it('should successfully login with Microsoft Entra authentication', function(done) { + const command = `node "${tfxPath}" login --service-url "${serverUrl}" --auth-type entra --no-prompt`; + const env = { ...process.env, TFX_ENTRA_TOKEN: 'test-entra-token' }; + + execAsyncWithLogging(command, 'login with Microsoft Entra authentication', { env }) + .then(({ stdout, stderr }) => { + const cleanOutput = stripColors(stdout); + const cleanError = stripColors(stderr || ''); + + assert(cleanOutput.includes('Logged in successfully'), + `Expected "Logged in successfully" but got output: "${cleanOutput}"`); + assert(cleanError.length === 0 || !cleanError.includes('error'), + `Expected no errors but got: "${cleanError}"`); + + done(); + }) + .catch((error) => { + done(error); + }); + }); + + it('should show a friendly error when Azure CLI is not installed for Microsoft Entra authentication', function(done) { + const command = `"${nodePath}" "${tfxPath}" login --service-url "${serverUrl}" --auth-type entra --no-prompt`; + const env: any = { ...process.env, PATH: '' }; + delete env.TFX_ENTRA_TOKEN; + delete env.AZURE_DEVOPS_ENTRA_TOKEN; + + execAsyncWithLogging(command, 'login with missing Azure CLI for Microsoft Entra authentication', { env }) + .then(() => { + assert.fail('Should have failed when Azure CLI is unavailable'); + }) + .catch((error) => { + try { + const errorOutput = stripColors(error.stderr || error.stdout || ''); + + assert(errorOutput.includes('Azure CLI (az) is required for Microsoft Entra authentication.'), + `Expected missing Azure CLI error but got: "${errorOutput}"`); + assert(errorOutput.includes('Install Azure CLI'), + `Expected Azure CLI installation guidance but got: "${errorOutput}"`); + assert(error.code !== 0, 'Should exit with non-zero code'); + + done(); + } catch (assertionError) { + done(assertionError); + } + }); + }); + it('should require service URL parameter', function(done) { const command = `node "${tfxPath}" login --auth-type basic --username testuser --password testpass --no-prompt`; @@ -155,7 +205,7 @@ describe('Server Integration Tests - Login and Authentication', function() { const errorOutput = stripColors(error.stderr || error.stdout || ''); // Should fail with specific unsupported auth type error - assert(errorOutput.includes("Unsupported auth type. Currently, 'pat' and 'basic' auth are supported."), + assert(errorOutput.includes("Unsupported auth type. Currently, 'pat', 'basic', and 'entra' auth are supported."), `Expected specific auth type error but got: "${errorOutput}"`); // Should have non-zero exit code @@ -232,6 +282,31 @@ describe('Server Integration Tests - Login and Authentication', function() { done(error); }); }); + + it('should reuse cached Microsoft Entra authentication for subsequent commands', function(done) { + const resetCommand = `node "${tfxPath}" reset --no-prompt`; + const loginCommand = `node "${tfxPath}" login --service-url "${serverUrl}" --auth-type entra --no-prompt`; + const buildListCommand = `node "${tfxPath}" build list --project "${testProject}" --no-prompt`; + const env = { ...process.env, TFX_ENTRA_TOKEN: 'cached-entra-token' }; + + execAsyncWithLogging(resetCommand, 'reset before Microsoft Entra cache test', { env }) + .then(() => execAsyncWithLogging(loginCommand, 'cache Microsoft Entra authentication', { env })) + .then(() => execAsyncWithLogging(buildListCommand, 'build list with cached Microsoft Entra authentication', { env })) + .then(({ stdout, stderr }) => { + const cleanOutput = stripColors(stdout); + const cleanError = stripColors(stderr || ''); + + assert(cleanOutput.includes('id : 1'), 'Should use cached Microsoft Entra auth for build list'); + assert(cleanOutput.includes('definition name : Sample Build Definition'), 'Should return build data after cached Microsoft Entra auth'); + assert(cleanError.length === 0 || !cleanError.includes('error'), + `Expected no errors but got: "${cleanError}"`); + + done(); + }) + .catch((error) => { + done(error); + }); + }); }); describe('Reset Command', function() { diff --git a/tests/test-utils/debug-exec.ts b/tests/test-utils/debug-exec.ts index 1a7d8667..ea9553e4 100644 --- a/tests/test-utils/debug-exec.ts +++ b/tests/test-utils/debug-exec.ts @@ -1,4 +1,4 @@ -import { exec } from 'child_process'; +import { exec, ExecOptions } from 'child_process'; import { promisify } from 'util'; const execAsync = promisify(exec); @@ -7,6 +7,19 @@ const execAsync = promisify(exec); * Enhanced debug logging utility for test commands */ export class DebugLogger { + private static stripNodeWarnings(content?: string): string { + if (!content) { + return ''; + } + + return content + .split(/\r?\n/) + .filter(line => line.trim() !== '') + .filter(line => !/^\(node:\d+\) \[DEP\d+\] DeprecationWarning:/.test(line)) + .filter(line => !line.includes('(Use `node --trace-deprecation')) + .join('\n'); + } + private static isEnabled(): boolean { return process.env.DEBUG_CLI_OUTPUT === 'true'; } @@ -60,16 +73,24 @@ export class DebugLogger { * @param description Optional description for the command * @returns Promise with stdout and stderr */ - static async execWithLogging(command: string, description?: string): Promise<{ stdout: string; stderr: string }> { + static async execWithLogging(command: string, description?: string, options?: ExecOptions): Promise<{ stdout: string; stderr: string }> { const logTitle = description ? `${description}` : 'COMMAND EXECUTION'; this.logSection(logTitle, `Command: ${command}`); try { - const result = await execAsync(command); - this.logResult(true, result.stdout, result.stderr); - return result; + const result = await execAsync(command, { ...(options || {}), encoding: 'utf8' } as any) as unknown as { + stdout: string; + stderr: string; + }; + const sanitizedResult = { + stdout: result.stdout, + stderr: this.stripNodeWarnings(result.stderr), + }; + this.logResult(true, sanitizedResult.stdout, sanitizedResult.stderr); + return sanitizedResult; } catch (error: any) { + error.stderr = this.stripNodeWarnings(error.stderr); this.logResult(false, error.stdout, error.stderr); throw error; } @@ -109,6 +130,6 @@ export class DebugLogger { /** * Convenience function for backward compatibility */ -export async function execAsyncWithLogging(command: string, description?: string): Promise<{ stdout: string; stderr: string }> { - return DebugLogger.execWithLogging(command, description); +export async function execAsyncWithLogging(command: string, description?: string, options?: ExecOptions): Promise<{ stdout: string; stderr: string }> { + return DebugLogger.execWithLogging(command, description, options); } From 98f69f64b5cc5ea24442c161de99bb6fc97e80ca Mon Sep 17 00:00:00 2001 From: Gabriel Castro Date: Tue, 12 May 2026 09:09:32 -0700 Subject: [PATCH 2/2] PR comments --- app/lib/entra.ts | 16 +++++++--------- app/lib/tfcommand.ts | 2 +- tests/build-server-integration-tests.ts | 23 +++++++++++++++++++++++ tests/server-integration-login.ts | 1 - 4 files changed, 31 insertions(+), 11 deletions(-) diff --git a/app/lib/entra.ts b/app/lib/entra.ts index dfa81f66..36d6ce14 100644 --- a/app/lib/entra.ts +++ b/app/lib/entra.ts @@ -6,7 +6,7 @@ const execAsync = promisify(exec); const AZURE_DEVOPS_SCOPE = "https://app.vssps.visualstudio.com/.default"; const AZURE_DEVOPS_RESOURCE = "499b84ac-1321-427f-aa17-267ca6975798"; -const ENTRA_TOKEN_ENV_VARS = ["TFX_ENTRA_TOKEN", "AZURE_DEVOPS_ENTRA_TOKEN"]; +const ENTRA_TOKEN_ENV_VAR = "TFX_ENTRA_TOKEN"; function isAzureCliMissing(err: any): boolean { const errorText = [err && err.stderr, err && err.message] @@ -31,17 +31,15 @@ function isAzureCliMissing(err: any): boolean { function getAzureCliMissingError(): Error { return new Error( - "Azure CLI (az) is required for Microsoft Entra authentication. Install Azure CLI and run 'az login' (or 'az login --service-principal' / 'az login --identity'), or set TFX_ENTRA_TOKEN or AZURE_DEVOPS_ENTRA_TOKEN.", + "Azure CLI (az) is required for Microsoft Entra authentication. Install Azure CLI and run 'az login' (or 'az login --service-principal' / 'az login --identity'), or set TFX_ENTRA_TOKEN.", ); } function getEntraTokenFromEnvironment(): string { - for (const envVar of ENTRA_TOKEN_ENV_VARS) { - const token = process.env[envVar]; - if (token && token.trim()) { - trace.debug(`Using Microsoft Entra token from ${envVar}.`); - return token.trim(); - } + const token = process.env[ENTRA_TOKEN_ENV_VAR]; + if (token && token.trim()) { + trace.debug(`Using Microsoft Entra token from ${ENTRA_TOKEN_ENV_VAR}.`); + return token.trim(); } return null; @@ -98,7 +96,7 @@ export async function getEntraAccessToken(): Promise { .filter((message, index, messages) => messages.indexOf(message) === index); let message = - "Unable to acquire a Microsoft Entra access token. Run 'az login' (or 'az login --service-principal' / 'az login --identity') first, or set TFX_ENTRA_TOKEN or AZURE_DEVOPS_ENTRA_TOKEN."; + "Unable to acquire a Microsoft Entra access token. Run 'az login' (or 'az login --service-principal' / 'az login --identity') first, or set TFX_ENTRA_TOKEN."; if (details.length > 0) { message += " Azure CLI output: " + details.join(" "); diff --git a/app/lib/tfcommand.ts b/app/lib/tfcommand.ts index b55f5124..249e5b13 100644 --- a/app/lib/tfcommand.ts +++ b/app/lib/tfcommand.ts @@ -443,7 +443,7 @@ export abstract class TfCommand { if (normalizedAuthType === "entra") { if (token || username || password) { throw new Error( - "Auth type 'entra' uses a Microsoft Entra token from Azure CLI or the TFX_ENTRA_TOKEN/AZURE_DEVOPS_ENTRA_TOKEN environment variables. Do not pass --token, --username, or --password.", + "Auth type 'entra' uses a Microsoft Entra token from Azure CLI or the TFX_ENTRA_TOKEN environment variable. Do not pass --token, --username, or --password.", ); } diff --git a/tests/build-server-integration-tests.ts b/tests/build-server-integration-tests.ts index 7e0191a0..0ccb98c6 100644 --- a/tests/build-server-integration-tests.ts +++ b/tests/build-server-integration-tests.ts @@ -13,6 +13,7 @@ declare function after(fn: Function): void; const tfxPath = path.resolve(__dirname, '../../_build/tfx-cli.js'); const samplesPath = path.resolve(__dirname, '../build-samples'); +const nodePath = process.execPath; describe('Build Commands - Server Integration Tests', function() { let mockServer: MockDevOpsServer; @@ -582,5 +583,27 @@ describe('Build Commands - Server Integration Tests', function() { done(); }); }); + + it('should throw a friendly error when entra auth is used without Azure CLI installed', function(done) { + const command = `"${nodePath}" "${tfxPath}" build list --service-url "${serverUrl}" --project "${testProject}" --auth-type entra --no-prompt`; + const env: any = { ...process.env, PATH: '' }; + delete env.TFX_ENTRA_TOKEN; + + execAsyncWithLogging(command, 'build list with missing Azure CLI for Microsoft Entra authentication', { env }) + .then(() => { + assert.fail('Should have failed when Azure CLI is unavailable'); + }) + .catch((error) => { + const errorOutput = stripColors(error.stderr || error.stdout || ''); + + assert(errorOutput.includes('Azure CLI (az) is required for Microsoft Entra authentication.'), + `Expected missing Azure CLI error but got: "${errorOutput}"`); + assert(errorOutput.includes('Install Azure CLI'), + `Expected Azure CLI installation guidance but got: "${errorOutput}"`); + assert(error.code !== 0, 'Should exit with non-zero code'); + + done(); + }); + }); }); }); diff --git a/tests/server-integration-login.ts b/tests/server-integration-login.ts index 357465e4..a517e97f 100644 --- a/tests/server-integration-login.ts +++ b/tests/server-integration-login.ts @@ -120,7 +120,6 @@ describe('Server Integration Tests - Login and Authentication', function() { const command = `"${nodePath}" "${tfxPath}" login --service-url "${serverUrl}" --auth-type entra --no-prompt`; const env: any = { ...process.env, PATH: '' }; delete env.TFX_ENTRA_TOKEN; - delete env.AZURE_DEVOPS_ENTRA_TOKEN; execAsyncWithLogging(command, 'login with missing Azure CLI for Microsoft Entra authentication', { env }) .then(() => {