From 4c20c3fe6b47060b6fa77a2fa5fef83beb6c56c4 Mon Sep 17 00:00:00 2001 From: Jeff Young Date: Thu, 13 Jul 2017 13:54:47 -0400 Subject: [PATCH] Add 'device flow' authentication option (#282) * Add 'device flow' authentication option * Add link to video --- README.md | 11 ++-- ThirdPartyNotices.txt | 60 +++++++++++++++++-- package.json | 2 + src/helpers/constants.ts | 9 +++ src/helpers/strings.ts | 7 +++ src/team-extension.ts | 121 ++++++++++++++++++++++++++++++++++----- 6 files changed, 184 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index f7d24a6ac4..2dd93c6956 100644 --- a/README.md +++ b/README.md @@ -34,15 +34,16 @@ To install the extension with the latest version of Visual Studio Code (version ## Authentication ### Visual Studio Team Services -If you are connecting to Team Services, you will need a personal access token (PAT) to securely access your account. The latest -version of the extension will prompt for your token and store it securely. In previous versions of the extension, you needed to create a -token and store it in your Visual Studio Code settings. +If you are connecting to Team Services, you will need a personal access token (PAT) to securely access your account. With the release of v1.121.0 of the extension, you have a choice of whether you would like to create a token yourself manually and provide it when prompted, or use a new experience in which you are authenticated to Team Services using your web browser. In the new experience, a personal access token is still created in your account (on your behalf) but only after you are authenticated. The created token has *All Scopes* permissions but can be updated in your profile settings. Both tokens (manual or the new experience) are stored securely on your machine. -If you do not have a personal access token yet, you will need to create one on your Team Services account. -To create the token, go [here](https://aka.ms/gtgzt4) to read how. You can also [view our video](https://youtu.be/t6gGfj8WOgg) on how to do the same. +#### Manual Token Creation +Should you wish to create a personal access token yourself, go [here](https://aka.ms/gtgzt4) to read how. You can also [view our video](https://youtu.be/t6gGfj8WOgg) on how to do the same. * Git repositories require that you create your token with the **Build (read)**, **Code (read)** and **Work items (read)** scopes to ensure full functionality. You can also use *All Scopes*, but the minimum required scopes are those listed above. * TFVC repositories require tokens with *All Scopes*. Anything less will cause the extension to fail. +#### Browser-based Authentication +When using the new authentication experience, you will be prompted to copy a *device code* used to identify yourself to the authentication system. Once you accept the prompt to begin authentication, your default web browser will be opened to a login page. After supplying that device code and having it verified, you will then be prompted to authenticate with Team Services normally (e.g., username and password, multi-factor authentication, etc.). Once you are authenticated to Team Services, a personal access token will be created for you and the extension will be initialized normally. To see what this experience is like, [view this video](https://youtu.be/HnDNdm1WCIo). + ### Team Foundation Server If you are connecting to Team Foundation Server, you will only need your NTLM credentials (domain name, account name and password). It is assumed that you have the proper permissions on the TFS Server. diff --git a/ThirdPartyNotices.txt b/ThirdPartyNotices.txt index 5b023cff4c..166497eb17 100644 --- a/ThirdPartyNotices.txt +++ b/ThirdPartyNotices.txt @@ -18,12 +18,14 @@ otherwise. 7. parse-git-config (https://github.com/jonschlinkert/parse-git-config) 8. path (https://github.com/jinder/path) 9. readable-stream (https://github.com/nodejs/readable-stream) -10. underscore (https://github.com/jashkenas/underscore) -11. uuid (https://github.com/kelektiv/node-uuid) -12. vso-node-api (https://github.com/Microsoft/vso-node-api) -13. winston (https://github.com/winstonjs/winston) -14. xml2js (https://github.com/Leonidas-from-XIV/node-xml2js) -15. xmldoc (https://github.com/nfarina/xmldoc) +10. request-promise-native (https://github.com/request/request-promise-native) +11. underscore (https://github.com/jashkenas/underscore) +12. uuid (https://github.com/kelektiv/node-uuid) +13. vso-node-api (https://github.com/Microsoft/vso-node-api) +14. vsts-device-flow-auth (https://github.com/Microsoft/vsts-device-flow-auth) +15. winston (https://github.com/winstonjs/winston) +16. xml2js (https://github.com/Leonidas-from-XIV/node-xml2js) +17. xmldoc (https://github.com/nfarina/xmldoc) %% Application Insights for Node.js NOTICES, INFORMATION, AND LICENSE BEGIN HERE @@ -251,6 +253,26 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ========================================= END OF readable-stream NOTICES, INFORMATION, AND LICENSE +%% request-promise-native NOTICES, INFORMATION, AND LICENSE BEGIN HERE +========================================= +ISC License + +Copyright (c) 2017, Nicolai Kamenzky and contributors + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +========================================= +END OF request-promise-native NOTICES, INFORMATION, AND LICENSE + %% underscore NOTICES, INFORMATION, AND LICENSE BEGIN HERE ========================================= Copyright (c) 2009-2016 Jeremy Ashkenas, DocumentCloud and Investigative @@ -332,6 +354,32 @@ SOFTWARE. ========================================= END OF vso-node-api NOTICES, INFORMATION, AND LICENSE +%% vsts-device-flow-auth NOTICES, INFORMATION, AND LICENSE BEGIN HERE +========================================= +MIT License + +Copyright (c) Microsoft Corporation. All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE +========================================= +END OF vsts-device-flow-auth NOTICES, INFORMATION, AND LICENSE + %% winston NOTICES, INFORMATION, AND LICENSE BEGIN HERE ========================================= The MIT License (MIT) diff --git a/package.json b/package.json index 5bdd7f4580..436c7a9da1 100644 --- a/package.json +++ b/package.json @@ -515,10 +515,12 @@ "parse-git-config": "^0.3.1", "path": "^0.12.7", "readable-stream": "^2.1.4", + "request-promise-native": "^1.0.4", "underscore": "^1.8.3", "url": "^0.11.0", "uuid": "^3.0.1", "vso-node-api": "^5.1.1", + "vsts-device-flow-auth": "^1.121.0", "winston": "2.3.1", "xml2js": "^0.4.17", "xmldoc": "^0.5.1" diff --git a/src/helpers/constants.ts b/src/helpers/constants.ts index a5dc0d6d85..9f5096a65f 100644 --- a/src/helpers/constants.ts +++ b/src/helpers/constants.ts @@ -42,6 +42,13 @@ export class CommandNames { static ViewPinnedQueryWorkItems: string = CommandNames.CommandPrefix + "ViewPinnedQueryWorkItems"; } +export class DeviceFlowConstants { + static ManualOption: string = "manual"; + static DeviceFlowOption: string = "deviceflow"; + static ClientId: string = "97877f11-0fc6-4aee-b1ff-febb0519dd00"; + static RedirectUri: string = "https://java.visualstudio.com"; +} + export class TfvcCommandNames { static CommandPrefix: string = "tfvc."; static Checkin: string = TfvcCommandNames.CommandPrefix + "Checkin"; @@ -82,8 +89,10 @@ export class SettingNames { export class TelemetryEvents { static TelemetryPrefix: string = Constants.ExtensionName + "/"; static AssociateWorkItems: string = TelemetryEvents.TelemetryPrefix + "associateworkitems"; + static DeviceFlowPat: string = TelemetryEvents.TelemetryPrefix + "deviceflowpat"; static ExternalRepository: string = TelemetryEvents.TelemetryPrefix + "externalrepo"; static Installed: string = TelemetryEvents.TelemetryPrefix + "installed"; + static ManualPat: string = TelemetryEvents.TelemetryPrefix + "manualpat"; static OpenAdditionalQueryResults: string = TelemetryEvents.TelemetryPrefix + "openaddlqueryresults"; static OpenBlamePage: string = TelemetryEvents.TelemetryPrefix + "openblame"; static OpenBuildSummaryPage: string = TelemetryEvents.TelemetryPrefix + "openbuildsummary"; diff --git a/src/helpers/strings.ts b/src/helpers/strings.ts index 17f711ee2b..2f723bfe5e 100644 --- a/src/helpers/strings.ts +++ b/src/helpers/strings.ts @@ -24,6 +24,13 @@ export class Strings { static NoSourceFileForBlame: string = "A source file must be opened to show blame information."; static UserMustSignIn: string = "You are signed out. Please run the 'team signin' command."; + static DeviceFlowAuthenticatingToTeamServices: string = "Authenticating to Team Services (%s)..."; + static DeviceFlowCopyCode: string = "Copy this code and then press Enter to start the authentication process"; + static DeviceFlowManualPrompt: string = "Provide an access token manually (current experience)"; + static DeviceFlowPrompt: string = "Authenticate and get an access token automatically (new experience)"; + static DeviceFlowPlaceholder: string = "Choose your method of authenticating to Team Services..."; + static ErrorRequestingToken: string = "An error occurred requesting a personal access token for %s."; + static SendAFrown: string = "Send a Frown"; static SendASmile: string = "Send a Smile"; static SendEmailPrompt: string = "(Optional) Provide your email address"; diff --git a/src/team-extension.ts b/src/team-extension.ts index 7d0b6a0b00..b4e1c00935 100644 --- a/src/team-extension.ts +++ b/src/team-extension.ts @@ -4,13 +4,15 @@ *--------------------------------------------------------------------------------------------*/ "use strict"; -import { scm, StatusBarAlignment, StatusBarItem, window } from "vscode"; +import { scm, StatusBarAlignment, StatusBarItem, ProgressLocation, window } from "vscode"; +import { DeviceFlowAuthenticator, DeviceFlowDetails, IDeviceFlowAuthenticationOptions, IDeviceFlowTokenOptions } from "vsts-device-flow-auth"; import { PinnedQuerySettings } from "./helpers/settings"; -import { CommandNames, Constants, TelemetryEvents, TfvcTelemetryEvents, WitTypes } from "./helpers/constants"; +import { CommandNames, Constants, DeviceFlowConstants, TelemetryEvents, TfvcTelemetryEvents, WitTypes } from "./helpers/constants"; import { Logger } from "./helpers/logger"; import { Strings } from "./helpers/strings"; +import { UserAgentProvider } from "./helpers/useragentprovider"; import { Utils } from "./helpers/utils"; -import { ButtonMessageItem, VsCodeUtils } from "./helpers/vscodeutils"; +import { BaseQuickPickItem, ButtonMessageItem, VsCodeUtils } from "./helpers/vscodeutils"; import { RepositoryType } from "./contexts/repositorycontext"; import { BuildClient } from "./clients/buildclient"; import { GitClient } from "./clients/gitclient"; @@ -19,6 +21,7 @@ import { Telemetry } from "./services/telemetry"; import { ExtensionManager } from "./extensionmanager"; import * as os from "os"; +import * as util from "util"; export class TeamExtension { private _manager: ExtensionManager; @@ -32,6 +35,7 @@ export class TeamExtension { private _pollingTimer: NodeJS.Timer; private _initialTimer: NodeJS.Timer; private _signedOut: boolean = false; + private _signingIn: boolean = false; constructor(manager: ExtensionManager) { this._manager = manager; @@ -63,10 +67,83 @@ export class TeamExtension { return this._signedOut; } + //Prompts user for either manual or device-flow mechanism for acquiring a personal access token. + //If manual, we provide the same experience as we always have + //If device-flow (automatic), we provide the new 'device flow' experience + private async requestPersonalAccessToken(): Promise { + const choices: BaseQuickPickItem[] = []; + choices.push({ label: Strings.DeviceFlowManualPrompt, description: undefined, id: DeviceFlowConstants.ManualOption }); + choices.push({ label: Strings.DeviceFlowPrompt, description: undefined, id: DeviceFlowConstants.DeviceFlowOption }); + + const choice: BaseQuickPickItem = await window.showQuickPick(choices, { matchOnDescription: false, placeHolder: Strings.DeviceFlowPlaceholder }); + if (choice) { + if (choice.id === DeviceFlowConstants.ManualOption) { + Logger.LogDebug(`Manual personal access token option chosen.`); + const token: string = await window.showInputBox({ value: "", prompt: `${Strings.ProvideAccessToken} (${this._manager.ServerContext.RepoInfo.Account})`, placeHolder: "", password: true }); + if (token) { + Telemetry.SendEvent(TelemetryEvents.ManualPat); + } + return token; + } else if (choice.id === DeviceFlowConstants.DeviceFlowOption) { + Logger.LogDebug(`Device flow personal access token option chosen.`); + const authOptions: IDeviceFlowAuthenticationOptions = { + clientId: DeviceFlowConstants.ClientId, + redirectUri: DeviceFlowConstants.RedirectUri, + userAgent: `${UserAgentProvider.UserAgent}` + }; + const tokenOptions: IDeviceFlowTokenOptions = { + tokenDescription: `VSTS VSCode extension: ${this._manager.ServerContext.RepoInfo.AccountUrl} on ${os.hostname()}` + }; + const dfa: DeviceFlowAuthenticator = new DeviceFlowAuthenticator(this._manager.ServerContext.RepoInfo.AccountUrl, authOptions, tokenOptions); + const details: DeviceFlowDetails = await dfa.GetDeviceFlowDetails(); + //To sign in, use a web browser to open the page https://aka.ms/devicelogin and enter the code F3VXCTH2L to authenticate. + const value: string = await window.showInputBox({ value: details.UserCode, prompt: `${Strings.DeviceFlowCopyCode} (${details.VerificationUrl})`, placeHolder: undefined, password: false }); + if (value) { + //At this point, user has no way to cancel until our timeout expires. Before this point, they could + //cancel out of the showInputBox. After that, they will need to wait for the automatic cancel to occur. + Utils.OpenUrl(details.VerificationUrl); + + //FUTURE: Could we display a message that allows the user to cancel the authentication? If they escape from the + //message or click Close, they wouldn't have that chance any longer. If they leave the message displaying, they + //have an opportunity to cancel. However, once authenticated, we no longer have an ability to close the message + //automatically or change the message that's displayed. :-/ + + //FUTURE: Add a 'button' on the status bar that can be used to cancel the authentication + + //Wait for up to 5 minutes before we cancel the stauts polling (Azure's default is 900s/15 minutes) + const timeout: number = 5 * 60 * 1000; + /* tslint:disable:align */ + const timer: NodeJS.Timer = setTimeout(() => { + Logger.LogDebug(`Device flow authentication canceled after ${timeout}ms.`); + dfa.Cancel(true); //throw on canceling + }, timeout); + /* tslint:enable:align */ + + //We need to await on withProgress here because we need a token before continuing forward + const title: string = util.format(Strings.DeviceFlowAuthenticatingToTeamServices, details.UserCode); + const token: string = await window.withProgress({ location: ProgressLocation.Window, title: title }, async () => { + const accessToken: string = await dfa.WaitForPersonalAccessToken(); + //Since we will cancel automatically after timeout, if we _do_ get an accessToken then we need to call clearTimeout + if (accessToken) { + clearTimeout(timer); + } + return accessToken; + }); + + return token; + } else { + Logger.LogDebug(`User has canceled the device flow authentication mechanism.`); + } + } + } + return undefined; + } + public async Signin() { // For Signin, first we need to verify _serverContext if (this._manager.ServerContext !== undefined && this._manager.ServerContext.RepoInfo !== undefined && this._manager.ServerContext.RepoInfo.IsTeamFoundation === true) { this._signedOut = false; + Logger.LogDebug(`Starting sign in process`); if (this._manager.ServerContext.RepoInfo.IsTeamFoundationServer === true) { const defaultUsername : string = this.getDefaultUsername(); const username: string = await window.showInputBox({ value: defaultUsername || "", prompt: Strings.ProvideUsername + " (" + this._manager.ServerContext.RepoInfo.Account + ")", placeHolder: "", password: false }); @@ -76,6 +153,7 @@ export class TeamExtension { Logger.LogInfo("Signin: Username and Password provided as authentication."); this._manager.CredentialManager.StoreCredentials(this._manager.ServerContext.RepoInfo.Host, username, password).then(() => { // We don't test the credentials to make sure they're good here. Do so on the next command that's run. + Logger.LogDebug(`Reinitializing after successfully storing credentials for Team Foundation Server.`); this._manager.Reinitialize(); }).catch((err) => { // TODO: Should the message direct the user to open an issue? send feedback? @@ -84,19 +162,31 @@ export class TeamExtension { }); } } - } else if (this._manager.ServerContext.RepoInfo.IsTeamServices === true) { - // Until Device Flow, we can prompt for the PAT for Team Services - const token: string = await window.showInputBox({ value: "", prompt: Strings.ProvideAccessToken + " (" + this._manager.ServerContext.RepoInfo.Account + ")", placeHolder: "", password: true }); - if (token !== undefined) { - Logger.LogInfo("Signin: Personal Access Token provided as authentication."); - this._manager.CredentialManager.StoreCredentials(this._manager.ServerContext.RepoInfo.Host, Constants.OAuth, token.trim()).then(() => { - this._manager.Reinitialize(); - }).catch((err) => { - // TODO: Should the message direct the user to open an issue? send feedback? - const msg: string = Strings.UnableToStoreCredentials + this._manager.ServerContext.RepoInfo.Host; - this._manager.ReportError(err, msg, true); - }); + } else if (this._manager.ServerContext.RepoInfo.IsTeamServices === true && !this._signingIn) { + this._signingIn = true; + try { + const token: string = await this.requestPersonalAccessToken(); + if (token !== undefined) { + Logger.LogInfo(`Signin: Personal Access Token provided as authentication.`); + this._manager.CredentialManager.StoreCredentials(this._manager.ServerContext.RepoInfo.Host, Constants.OAuth, token.trim()).then(() => { + Logger.LogDebug(`Reinitializing after successfully storing credentials for Team Services.`); + this._manager.Reinitialize(); + }).catch((err) => { + // TODO: Should the message direct the user to open an issue? send feedback? + const msg: string = `${Strings.UnableToStoreCredentials} ${this._manager.ServerContext.RepoInfo.Host}`; + this._manager.ReportError(err, msg, true); + }); + } + } catch (err) { + let msg: string = util.format(Strings.ErrorRequestingToken, this._manager.ServerContext.RepoInfo.AccountUrl); + if (err.message) { + msg = `${msg} (${err.message})`; + } + Logger.LogError(msg); + //FUTURE: Add a ButtonMessageItem to provide additional help? Log a bug? + VsCodeUtils.ShowErrorMessage(msg); } + this._signingIn = false; } } else { //If _manager has an error to display, display it and forgo the other. Otherwise, show the default error message. @@ -116,6 +206,7 @@ export class TeamExtension { public Signout() { // For Logout, we just need to verify _serverContext and don't want to set this._errorMessage if (this._manager.ServerContext !== undefined && this._manager.ServerContext.RepoInfo !== undefined && this._manager.ServerContext.RepoInfo.IsTeamFoundation === true) { + Logger.LogDebug(`Starting sign out process`); this._manager.CredentialManager.RemoveCredentials(this._manager.ServerContext.RepoInfo.Host).then(() => { Logger.LogInfo(`Signout: Removed credentials for host '${this._manager.ServerContext.RepoInfo.Host}'`); }).catch((err) => {