Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,11 @@ tfx <command> --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.

Expand All @@ -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.
Expand Down
37 changes: 30 additions & 7 deletions app/exec/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,37 @@ export interface LoginResult {
* Facilitates a "log in" to a service by caching credentials.
*/
export class Login extends TfCommand<CoreArguments, LoginResult> {
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<string> {
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<LoginResult> {
trace.debug("Login.exec");
return this.commandArgs.serviceUrl.val().then(async collectionUrl => {
Expand All @@ -36,12 +64,7 @@ export class Login extends TfCommand<CoreArguments, LoginResult> {
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());
Expand Down
108 changes: 108 additions & 0 deletions app/lib/entra.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
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_VAR = "TFX_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.",
);
}

function getEntraTokenFromEnvironment(): string {
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;
}

async function getTokenFromAzureCli(command: string): Promise<string> {
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<string> {
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.";

if (details.length > 0) {
message += " Azure CLI output: " + details.join(" ");
}

throw new Error(message);
}
}
}
2 changes: 1 addition & 1 deletion app/lib/errorhandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: " +
Expand Down
141 changes: 88 additions & 53 deletions app/lib/tfcommand.ts
Original file line number Diff line number Diff line change
@@ -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");
Expand All @@ -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 {
Expand Down Expand Up @@ -298,7 +298,7 @@ export abstract class TfCommand<TArguments extends CoreArguments, TResult> {
this.registerCommandArgument(
["authType"],
"Authentication Method",
"Method of authentication ('pat' or 'basic').",
"Method of authentication ('pat', 'basic', or 'entra').",
args.StringArgument,
"pat",
);
Expand All @@ -314,7 +314,7 @@ export abstract class TfCommand<TArguments extends CoreArguments, TResult> {
"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",
Expand Down Expand Up @@ -403,63 +403,98 @@ export abstract class TfCommand<TArguments extends CoreArguments, TResult> {
* 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<BasicCredentialHandler> {
private async getEntraHandler(): Promise<IRequestHandler> {
const accessToken = await getEntraAccessToken();
return getBearerHandler(accessToken);
}

private async getStoredCredentialHandler(credString: string): Promise<IRequestHandler> {
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<IRequestHandler> {
const normalizedAuthType = (authType || "pat").toLowerCase();

if (normalizedAuthType === "entra") {
if (token || username || password) {
throw new Error(
Comment thread
gpcastro marked this conversation as resolved.
"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.",
);
}

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<IRequestHandler> {
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);
});
}

Expand Down
Loading
Loading