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
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,32 @@ Examples of valid URLs are:
* `https://marketplace.visualstudio.com`
* `https://youraccount.visualstudio.com/DefaultCollection`

#### Environment variable

You can also authenticate using the `AZURE_DEVOPS_TOKEN` environment variable instead of storing credentials or passing them via command-line arguments:

**Linux/OSX:**
```bash
export AZURE_DEVOPS_TOKEN=your-pat-token-here
tfx extension show --publisher your-publisher --extension-id your-extension
```

**Windows:**
```bash
set AZURE_DEVOPS_TOKEN=your-pat-token-here
tfx extension show --publisher your-publisher --extension-id your-extension
```

**PowerShell:**
```bash
$env:AZURE_DEVOPS_TOKEN="your-pat-token-here"
tfx extension show --publisher your-publisher --extension-id your-extension
```

#### Token from stdin

For enhanced security in CI/CD pipelines or when working with secret management tools, you can pass your token via stdin using the `--token-from-stdin` parameter. This prevents the token from appearing in shell history or process listings. See [Token from stdin](docs/token-from-stdin.md) for more details.

#### Basic auth

You can also use basic authentication by passing the `--auth-type basic` parameter (see [Configuring Basic Auth](docs/configureBasicAuth.md) for details).
Expand Down
21 changes: 21 additions & 0 deletions app/lib/arguments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,27 @@ export class SilentStringArgument extends StringArgument {
public silent = true;
}

/**
* Argument that reads its value from stdin immediately when the flag is present.
* This is useful for reading sensitive values like tokens from stdin without
* interfering with the interactive prompt system.
*/
export class StdinStringArgument extends Argument<string> {
public silent = true;

protected async getValue(argParams: string[] | Promise<string[]>): Promise<string> {
// If this argument was provided, read from stdin immediately
const getStdin = await import("get-stdin");
const stdinContent = await getStdin.default();

if (!stdinContent || !stdinContent.trim()) {
throw new Error(`No input provided on stdin for argument ${this.name}`);
}

return stdinContent.trim();
}
}

export function getOptionsCache(): Promise<any> {
let cache = new DiskCache("tfx");
return cache.itemExists("cache", "command-options").then(cacheExists => {
Expand Down
191 changes: 115 additions & 76 deletions app/lib/tfcommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export interface CoreArguments {
serviceUrl: args.StringArgument;
password: args.SilentStringArgument;
token: args.SilentStringArgument;
tokenFromStdin: args.StdinStringArgument;
save: args.BooleanArgument;
username: args.StringArgument;
output: args.StringArgument;
Expand Down Expand Up @@ -73,27 +74,37 @@ export abstract class TfCommand<TArguments extends CoreArguments, TResult> {
protected initialize(): Promise<Executor<any>> {
// First validate arguments, then proceed with help or normal execution
this.initialized = this.validateArguments().then(() => {
// Check for mutually exclusive authentication arguments
const groupedArgs = this.getGroupedArgs();
const hasToken = groupedArgs["token"] !== undefined || groupedArgs["-t"] !== undefined;
const hasTokenFromStdin = groupedArgs["tokenFromStdin"] !== undefined;

if (hasToken && hasTokenFromStdin) {
trace.error("The arguments --token and --token-from-stdin are mutually exclusive. Please use only one.");
this.commandArgs.help.setValue(true);
}

return this.commandArgs.help.val().then(needHelp => {
if (needHelp) {
return this.run.bind(this, this.getHelp.bind(this));
} else {
// Set the fiddler proxy
return this.commandArgs.fiddler
.val()
.then(useProxy => {
if (useProxy) {
process.env.HTTP_PROXY = "http://127.0.0.1:8888";
}
})
.then(() => {
// Set custom proxy
return this.commandArgs.proxy.val(true).then(proxy => {
if (proxy) {
process.env.HTTP_PROXY = proxy;
// Set the fiddler proxy
return this.commandArgs.fiddler
.val()
.then(useProxy => {
if (useProxy) {
process.env.HTTP_PROXY = "http://127.0.0.1:8888";
}
});
})
.then(() => {
})
.then(() => {
// Set custom proxy
return this.commandArgs.proxy.val(true).then(proxy => {
if (proxy) {
process.env.HTTP_PROXY = proxy;
}
});
})
.then(() => {
// Set the no-prompt flag
return this.commandArgs.noPrompt.val(true).then(noPrompt => {
common.NO_PROMPT = noPrompt;
Expand Down Expand Up @@ -315,6 +326,12 @@ export abstract class TfCommand<TArguments extends CoreArguments, TResult> {
args.SilentStringArgument,
);
this.registerCommandArgument(["token", "-t"], "Personal access token", null, args.SilentStringArgument);
this.registerCommandArgument(
["tokenFromStdin"],
"Read token from stdin",
"Read the personal access token from stdin instead of prompting.",
args.StdinStringArgument,
);
this.registerCommandArgument(
["save"],
"Save settings",
Expand Down Expand Up @@ -404,65 +421,88 @@ export abstract class TfCommand<TArguments extends CoreArguments, TResult> {
* Else, check the authType - if it is "pat", prompt for a token
* If it is "basic", prompt for username and password.
*/
protected getCredentials(serviceUrl: string, useCredStore: boolean = true): Promise<BasicCredentialHandler> {
return Promise.all([
protected async getCredentials(serviceUrl: string, useCredStore: boolean = true): Promise<BasicCredentialHandler> {
const [authTypeValue, tokenArg, tokenFromStdin, username, password] = await Promise.all([
this.commandArgs.authType.val(),
this.commandArgs.token.val(true),
this.commandArgs.tokenFromStdin.val(true),
this.commandArgs.username.val(true),
this.commandArgs.password.val(true),
]).then(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.");
}
});
}
]);

if (username && password) {
return getBasicHandler(username, password) as BasicCredentialHandler;
}

let resolvedToken = tokenArg || tokenFromStdin;
if (!resolvedToken) {
const envToken = process.env.AZURE_DEVOPS_TOKEN;
if (envToken && envToken.trim()) {
resolvedToken = envToken.trim();
}
});
}
if (resolvedToken) {
return getBasicHandler("OAuth", resolvedToken) as BasicCredentialHandler;
}

if (useCredStore) {
const storedCredentials = await this.tryGetCredentialFromStore(serviceUrl);
if (storedCredentials) {
return storedCredentials;
}
}

return this.promptForCredentials(authTypeValue);
}

protected async tryGetCredentialFromStore(serviceUrl: string): Promise<BasicCredentialHandler | undefined> {
try {
const credString = await getCredentialStore("tfx").getCredential(serviceUrl, "allusers");
return this.parseCredentialString(credString);
} catch (err) {
trace.debug("Credential store lookup failed: %s", err && err.message ? err.message : err);
return undefined;
}
}

private parseCredentialString(credString: string): BasicCredentialHandler {
if (!credString || credString.length <= 6) {
throw new Error("Could not get credentials from credential store.");
}

if (credString.substr(0, 3) === "pat") {
return getBasicHandler("OAuth", credString.substr(4)) as BasicCredentialHandler;
}

if (credString.substr(0, 5) === "basic") {
const rest = credString.substr(6);
const dividerIndex = rest.indexOf(":");
const parsedUsername = rest.substr(0, dividerIndex);
const parsedPassword = rest.substr(dividerIndex + 1);
if (dividerIndex > 0 && parsedUsername && parsedPassword) {
return getBasicHandler(parsedUsername, parsedPassword) as BasicCredentialHandler;
}
}

throw new Error("Could not get credentials from credential store.");
}

private async promptForCredentials(authTypeValue?: string): Promise<BasicCredentialHandler> {
const normalizedAuthType = (authTypeValue || "").toLowerCase();
if (normalizedAuthType === "pat") {
const promptedToken = await this.commandArgs.token.val();
return getBasicHandler("OAuth", promptedToken) as BasicCredentialHandler;
}
if (normalizedAuthType === "basic") {
const promptedUsername = await this.commandArgs.username.val();
const promptedPassword = await this.commandArgs.password.val();
return getBasicHandler(promptedUsername, promptedPassword) as BasicCredentialHandler;
}
throw new Error("Unsupported auth type. Currently, 'pat' and 'basic' auth are supported.");
}



public async getWebApi(options?: IRequestOptions): Promise<WebApi> {
// try to get value of skipCertValidation from cache
const tfxCache = new DiskCache("tfx");
Expand Down Expand Up @@ -607,14 +647,13 @@ export abstract class TfCommand<TArguments extends CoreArguments, TResult> {
result += singleArgData(arg, maxArgLen);
});

if (this.serverCommand) {
result += eol + cyan("Global server command arguments:") + eol;
["authType", "username", "password", "token", "serviceUrl", "fiddler", "proxy", "skipCertValidation"].forEach(arg => {
result += singleArgData(arg, 11);
});
}

result += eol + cyan("Global arguments:") + eol;
if (this.serverCommand) {
result += eol + cyan("Global server command arguments:") + eol;
["authType", "username", "password", "token", "tokenFromStdin", "serviceUrl", "fiddler", "proxy", "skipCertValidation"].forEach(arg => {
result += singleArgData(arg, 16);
});
result += eol + gray(" Note: You can also authenticate using the AZURE_DEVOPS_TOKEN environment variable.") + eol;
} result += eol + cyan("Global arguments:") + eol;
["help", "save", "noColor", "noPrompt", "output", "json", "traceLevel", "debugLogStream"].forEach(arg => {
result += singleArgData(arg, 9);
});
Expand Down
13 changes: 12 additions & 1 deletion docs/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,11 +106,22 @@ In addition to all of the `extension create` options, the following options are
tfx extension publish --publisher mypublisher --manifest-js myextension.config.js --env mode=development --share-with myaccount
```

### Authentication

When you run the `publish` command, you need to authenticate to the Marketplace. There are multiple ways to provide your Personal Access Token (PAT):

1. **Command-line argument**: `--token your-pat-token` (not recommended for security reasons)
2. **Stdin**: `--token-from-stdin` - Read token from stdin (recommended for CI/CD, see [Token from stdin](token-from-stdin.md))
3. **Environment variable**: Set `AZURE_DEVOPS_TOKEN=your-pat-token`
4. **Stored credentials**: Run `tfx login` once to store credentials
5. **Interactive prompt**: If no credentials are provided, you will be prompted

For more information about obtaining a Personal Access Token, see [Publish from the command line](https://docs.microsoft.com/azure/devops/extend/publish/command-line?view=vsts).

### Tips

1. By default, `publish` first packages the extension using the same mechanism as `tfx extension create`. All options available for `create` are available for `publish`.
2. If an Extension with the same ID already exists publisher, the command will attempt to update the extension.
3. When you run the `publish` command, you will be prompted for a Personal Access Token to authenticate to the Marketplace. For more information about obtaining a Personal Access Token, see [Publish from the command line](https://docs.microsoft.com/azure/devops/extend/publish/command-line?view=vsts).



Expand Down
Loading