From 013132a5cde7733e8fc59f633c03018ca08db9a1 Mon Sep 17 00:00:00 2001 From: Tyler James Leonhardt Date: Mon, 3 Jul 2023 15:06:42 -0700 Subject: [PATCH] Re-work auth flows into separate file (#186939) The flows are crucial to the extension and I wanted to be able to declare which can be used when in a declarative way. This reworks the flows into that model. Additionally, it pulls in the Client ID and secret from a config which will allow us to not rely on the vscode.dev proxy on Desktop (because we can make a distro change that includes the secret... which isn't a real secret, says GitHub)... unfortunately we still need to rely on it for Web due to CORS but we're in a position where it will be easy to rip the proxy out when GH supports it. --- .../github-authentication/src/common/env.ts | 6 +- .../src/common/errors.ts | 10 + .../github-authentication/src/config.ts | 15 + extensions/github-authentication/src/flows.ts | 499 ++++++++++++++++++ .../github-authentication/src/github.ts | 57 +- .../github-authentication/src/githubServer.ts | 399 +------------- 6 files changed, 610 insertions(+), 376 deletions(-) create mode 100644 extensions/github-authentication/src/common/errors.ts create mode 100644 extensions/github-authentication/src/config.ts create mode 100644 extensions/github-authentication/src/flows.ts diff --git a/extensions/github-authentication/src/common/env.ts b/extensions/github-authentication/src/common/env.ts index 7b99a148373..ebc474936aa 100644 --- a/extensions/github-authentication/src/common/env.ts +++ b/extensions/github-authentication/src/common/env.ts @@ -29,6 +29,10 @@ export function isSupportedClient(uri: Uri): boolean { export function isSupportedTarget(type: AuthProviderType, gheUri?: Uri): boolean { return ( type === AuthProviderType.github || - /\.ghe\.com$/.test(gheUri!.authority) + isHostedGitHubEnterprise(gheUri!) ); } + +export function isHostedGitHubEnterprise(uri: Uri): boolean { + return /\.ghe\.com$/.test(uri.authority); +} diff --git a/extensions/github-authentication/src/common/errors.ts b/extensions/github-authentication/src/common/errors.ts new file mode 100644 index 00000000000..3ba3dfc006a --- /dev/null +++ b/extensions/github-authentication/src/common/errors.ts @@ -0,0 +1,10 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export const TIMED_OUT_ERROR = 'Timed out'; + +// These error messages are internal and should not be shown to the user in any way. +export const USER_CANCELLATION_ERROR = 'User Cancelled'; +export const NETWORK_ERROR = 'network error'; diff --git a/extensions/github-authentication/src/config.ts b/extensions/github-authentication/src/config.ts new file mode 100644 index 00000000000..30b9dd66265 --- /dev/null +++ b/extensions/github-authentication/src/config.ts @@ -0,0 +1,15 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export interface IConfig { + // The client ID of the GitHub OAuth app + gitHubClientId: string; + gitHubClientSecret?: string; +} + +// For easy access to mixin client ID and secret +export const Config: IConfig = { + gitHubClientId: '01ab8ac9400c4e429b23' +}; diff --git a/extensions/github-authentication/src/flows.ts b/extensions/github-authentication/src/flows.ts new file mode 100644 index 00000000000..f3f9277bdc1 --- /dev/null +++ b/extensions/github-authentication/src/flows.ts @@ -0,0 +1,499 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as path from 'path'; +import { ProgressLocation, Uri, commands, env, l10n, window } from 'vscode'; +import { Log } from './common/logger'; +import { Config } from './config'; +import { UriEventHandler } from './github'; +import { fetching } from './node/fetch'; +import { LoopbackAuthServer } from './node/authServer'; +import { promiseFromEvent } from './common/utils'; +import { isHostedGitHubEnterprise } from './common/env'; +import { NETWORK_ERROR, TIMED_OUT_ERROR, USER_CANCELLATION_ERROR } from './common/errors'; + +interface IGitHubDeviceCodeResponse { + device_code: string; + user_code: string; + verification_uri: string; + interval: number; +} + +interface IFlowOptions { + // GitHub.com + readonly supportsGitHubDotCom: boolean; + // A GitHub Enterprise Server that is hosted by an organization + readonly supportsGitHubEnterpriseServer: boolean; + // A GitHub Enterprise Server that is hosted by GitHub for an organization + readonly supportsHostedGitHubEnterprise: boolean; + + // Runtimes - there are constraints on which runtimes support which flows + readonly supportsWebWorkerExtensionHost: boolean; + readonly supportsRemoteExtensionHost: boolean; + + // Clients - see `isSupportedClient` in `common/env.ts` for what constitutes a supported client + readonly supportsSupportedClients: boolean; + readonly supportsUnsupportedClients: boolean; + + // Configurations - some flows require a client secret + readonly supportsNoClientSecret: boolean; +} + +export const enum GitHubTarget { + DotCom, + Enterprise, + HostedEnterprise +} + +export const enum ExtensionHost { + WebWorker, + Remote, + Local +} + +interface IFlowQuery { + target: GitHubTarget; + extensionHost: ExtensionHost; + isSupportedClient: boolean; +} + +interface IFlowTriggerOptions { + scopes: string; + baseUri: Uri; + logger: Log; + redirectUri: Uri; + nonce: string; + callbackUri: Uri; + uriHandler: UriEventHandler; + enterpriseUri?: Uri; +} + +interface IFlow { + label: string; + options: IFlowOptions; + trigger(options: IFlowTriggerOptions): Promise; +} + +async function exchangeCodeForToken( + logger: Log, + endpointUri: Uri, + redirectUri: Uri, + code: string, + enterpriseUri?: Uri +): Promise { + logger.info('Exchanging code for token...'); + + const clientSecret = Config.gitHubClientSecret; + if (!clientSecret) { + throw new Error('No client secret configured for GitHub authentication.'); + } + + const body = new URLSearchParams([ + ['code', code], + ['client_id', Config.gitHubClientId], + ['redirect_uri', redirectUri.toString(true)], + ['client_secret', clientSecret] + ]); + if (enterpriseUri) { + body.append('github_enterprise', enterpriseUri.toString(true)); + } + const result = await fetching(endpointUri.toString(true), { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + 'Content-Length': body.toString() + + }, + body: body.toString() + }); + + if (result.ok) { + const json = await result.json(); + logger.info('Token exchange success!'); + return json.access_token; + } else { + const text = await result.text(); + const error = new Error(text); + error.name = 'GitHubTokenExchangeError'; + throw error; + } +} + +const allFlows: IFlow[] = [ + new class UrlHandlerFlow implements IFlow { + label = l10n.t('url handler'); + options: IFlowOptions = { + supportsGitHubDotCom: true, + // Supporting GHES would be challenging because different versions + // used a different client ID. We could try to detect the version + // and use the right one, but that's a lot of work when we have + // other flows that work well. + supportsGitHubEnterpriseServer: false, + supportsHostedGitHubEnterprise: true, + supportsRemoteExtensionHost: true, + supportsWebWorkerExtensionHost: true, + // exchanging a code for a token requires a client secret + supportsNoClientSecret: false, + supportsSupportedClients: true, + supportsUnsupportedClients: false + }; + + async trigger({ + scopes, + baseUri, + redirectUri, + logger, + nonce, + callbackUri, + uriHandler, + enterpriseUri + }: IFlowTriggerOptions): Promise { + logger.info(`Trying without local server... (${scopes})`); + return await window.withProgress({ + location: ProgressLocation.Notification, + title: l10n.t({ + message: 'Signing in to {0}...', + args: [baseUri.authority], + comment: ['The {0} will be a url, e.g. github.com'] + }), + cancellable: true + }, async (_, token) => { + const promise = uriHandler.waitForCode(logger, scopes, nonce, token); + + const searchParams = new URLSearchParams([ + ['client_id', Config.gitHubClientId], + ['redirect_uri', redirectUri.toString(true)], + ['scope', scopes], + ['state', encodeURIComponent(callbackUri.toString(true))] + ]); + + // The extra toString, parse is apparently needed for env.openExternal + // to open the correct URL. + const uri = Uri.parse(baseUri.with({ + path: '/login/oauth/authorize', + query: searchParams.toString() + }).toString(true)); + await env.openExternal(uri); + + const code = await promise; + + const proxyEndpoints: { [providerId: string]: string } | undefined = await commands.executeCommand('workbench.getCodeExchangeProxyEndpoints'); + const endpointUrl = proxyEndpoints?.github + ? Uri.parse(`${proxyEndpoints.github}login/oauth/access_token`) + : baseUri.with({ path: '/login/oauth/access_token' }); + + const accessToken = await exchangeCodeForToken(logger, endpointUrl, redirectUri, code, enterpriseUri); + return accessToken; + }); + } + }, + new class LocalServerFlow implements IFlow { + label = l10n.t('local server'); + options: IFlowOptions = { + supportsGitHubDotCom: true, + // Supporting GHES would be challenging because different versions + // used a different client ID. We could try to detect the version + // and use the right one, but that's a lot of work when we have + // other flows that work well. + supportsGitHubEnterpriseServer: false, + supportsHostedGitHubEnterprise: true, + supportsRemoteExtensionHost: true, + supportsWebWorkerExtensionHost: true, + // exchanging a code for a token requires a client secret + supportsNoClientSecret: false, + supportsSupportedClients: true, + supportsUnsupportedClients: true + }; + async trigger({ + scopes, + baseUri, + redirectUri, + logger, + enterpriseUri + }: IFlowTriggerOptions): Promise { + logger.info(`Trying with local server... (${scopes})`); + return await window.withProgress({ + location: ProgressLocation.Notification, + title: l10n.t({ + message: 'Signing in to {0}...', + args: [baseUri.authority], + comment: ['The {0} will be a url, e.g. github.com'] + }), + cancellable: true + }, async (_, token) => { + const searchParams = new URLSearchParams([ + ['client_id', Config.gitHubClientId], + ['redirect_uri', redirectUri.toString(true)], + ['scope', scopes], + ]); + + const loginUrl = baseUri.with({ + path: '/login/oauth/authorize', + query: searchParams.toString() + }); + const server = new LoopbackAuthServer(path.join(__dirname, '../media'), loginUrl.toString(true)); + const port = await server.start(); + + let codeToExchange; + try { + env.openExternal(Uri.parse(`http://127.0.0.1:${port}/signin?nonce=${encodeURIComponent(server.nonce)}`)); + const { code } = await Promise.race([ + server.waitForOAuthResponse(), + new Promise((_, reject) => setTimeout(() => reject(TIMED_OUT_ERROR), 300_000)), // 5min timeout + promiseFromEvent(token.onCancellationRequested, (_, __, reject) => { reject(USER_CANCELLATION_ERROR); }).promise + ]); + codeToExchange = code; + } finally { + setTimeout(() => { + void server.stop(); + }, 5000); + } + + const accessToken = await exchangeCodeForToken( + logger, + baseUri.with({ path: '/login/oauth/access_token' }), + redirectUri, + codeToExchange, + enterpriseUri); + return accessToken; + }); + } + }, + new class DeviceCodeFlow implements IFlow { + label = l10n.t('device code'); + options: IFlowOptions = { + supportsGitHubDotCom: true, + supportsGitHubEnterpriseServer: true, + supportsHostedGitHubEnterprise: true, + supportsRemoteExtensionHost: true, + // CORS prevents this from working in web workers + supportsWebWorkerExtensionHost: false, + supportsNoClientSecret: true, + supportsSupportedClients: true, + supportsUnsupportedClients: true + }; + async trigger({ scopes, baseUri, logger }: IFlowTriggerOptions) { + logger.info(`Trying device code flow... (${scopes})`); + + // Get initial device code + const uri = baseUri.with({ + path: '/login/device/code', + query: `client_id=${Config.gitHubClientId}&scope=${scopes}` + }); + const result = await fetching(uri.toString(true), { + method: 'POST', + headers: { + Accept: 'application/json' + } + }); + if (!result.ok) { + throw new Error(`Failed to get one-time code: ${await result.text()}`); + } + + const json = await result.json() as IGitHubDeviceCodeResponse; + + const button = l10n.t('Copy & Continue to GitHub'); + const modalResult = await window.showInformationMessage( + l10n.t({ message: 'Your Code: {0}', args: [json.user_code], comment: ['The {0} will be a code, e.g. 123-456'] }), + { + modal: true, + detail: l10n.t('To finish authenticating, navigate to GitHub and paste in the above one-time code.') + }, button); + + if (modalResult !== button) { + throw new Error(USER_CANCELLATION_ERROR); + } + + await env.clipboard.writeText(json.user_code); + + const uriToOpen = await env.asExternalUri(Uri.parse(json.verification_uri)); + await env.openExternal(uriToOpen); + + return await this.waitForDeviceCodeAccessToken(baseUri, json); + } + + private async waitForDeviceCodeAccessToken( + baseUri: Uri, + json: IGitHubDeviceCodeResponse, + ): Promise { + return await window.withProgress({ + location: ProgressLocation.Notification, + cancellable: true, + title: l10n.t({ + message: 'Open [{0}]({0}) in a new tab and paste your one-time code: {1}', + args: [json.verification_uri, json.user_code], + comment: [ + 'The [{0}]({0}) will be a url and the {1} will be a code, e.g. 123-456', + '{Locked="[{0}]({0})"}' + ] + }) + }, async (_, token) => { + const refreshTokenUri = baseUri.with({ + path: '/login/oauth/access_token', + query: `client_id=${Config.gitHubClientId}&device_code=${json.device_code}&grant_type=urn:ietf:params:oauth:grant-type:device_code` + }); + + // Try for 2 minutes + const attempts = 120 / json.interval; + for (let i = 0; i < attempts; i++) { + await new Promise(resolve => setTimeout(resolve, json.interval * 1000)); + if (token.isCancellationRequested) { + throw new Error(USER_CANCELLATION_ERROR); + } + let accessTokenResult; + try { + accessTokenResult = await fetching(refreshTokenUri.toString(true), { + method: 'POST', + headers: { + Accept: 'application/json' + } + }); + } catch { + continue; + } + + if (!accessTokenResult.ok) { + continue; + } + + const accessTokenJson = await accessTokenResult.json(); + + if (accessTokenJson.error === 'authorization_pending') { + continue; + } + + if (accessTokenJson.error) { + throw new Error(accessTokenJson.error_description); + } + + return accessTokenJson.access_token; + } + + throw new Error(TIMED_OUT_ERROR); + }); + } + }, + new class PatFlow implements IFlow { + label = l10n.t('personal access token'); + options: IFlowOptions = { + supportsGitHubDotCom: true, + supportsGitHubEnterpriseServer: true, + supportsHostedGitHubEnterprise: true, + supportsRemoteExtensionHost: true, + supportsWebWorkerExtensionHost: true, + supportsNoClientSecret: true, + // PATs can't be used with Settings Sync so we don't enable this flow + // for supported clients + supportsSupportedClients: false, + supportsUnsupportedClients: true + }; + + async trigger({ scopes, baseUri, logger, enterpriseUri }: IFlowTriggerOptions) { + logger.info(`Trying to retrieve PAT... (${scopes})`); + + const button = l10n.t('Continue to GitHub'); + const modalResult = await window.showInformationMessage( + l10n.t('Continue to GitHub to create a Personal Access Token (PAT)'), + { + modal: true, + detail: l10n.t('To finish authenticating, navigate to GitHub to create a PAT then paste the PAT into the input box.') + }, button); + + if (modalResult !== button) { + throw new Error(USER_CANCELLATION_ERROR); + } + + const description = `${env.appName} (${scopes})`; + const uriToOpen = await env.asExternalUri(baseUri.with({ path: '/settings/tokens/new', query: `description=${description}&scopes=${scopes.split(' ').join(',')}` })); + await env.openExternal(uriToOpen); + const token = await window.showInputBox({ placeHolder: `ghp_1a2b3c4...`, prompt: `GitHub Personal Access Token - ${scopes}`, ignoreFocusOut: true }); + if (!token) { throw new Error(USER_CANCELLATION_ERROR); } + + const appUri = !enterpriseUri || isHostedGitHubEnterprise(enterpriseUri) + ? Uri.parse(`${baseUri.scheme}://api.${baseUri.authority}`) + : Uri.parse(`${baseUri.scheme}://${baseUri.authority}/api/v3`); + + const tokenScopes = await this.getScopes(token, appUri, logger); // Example: ['repo', 'user'] + const scopesList = scopes.split(' '); // Example: 'read:user repo user:email' + if (!scopesList.every(scope => { + const included = tokenScopes.includes(scope); + if (included || !scope.includes(':')) { + return included; + } + + return scope.split(':').some(splitScopes => { + return tokenScopes.includes(splitScopes); + }); + })) { + throw new Error(`The provided token does not match the requested scopes: ${scopes}`); + } + + return token; + } + + private async getScopes(token: string, serverUri: Uri, logger: Log): Promise { + try { + logger.info('Getting token scopes...'); + const result = await fetching(serverUri.toString(), { + headers: { + Authorization: `token ${token}`, + 'User-Agent': `${env.appName} (${env.appHost})` + } + }); + + if (result.ok) { + const scopes = result.headers.get('X-OAuth-Scopes'); + return scopes ? scopes.split(',').map(scope => scope.trim()) : []; + } else { + logger.error(`Getting scopes failed: ${result.statusText}`); + throw new Error(result.statusText); + } + } catch (ex) { + logger.error(ex.message); + throw new Error(NETWORK_ERROR); + } + } + } +]; + +export function getFlows(query: IFlowQuery) { + return allFlows.filter(flow => { + let useFlow: boolean = true; + switch (query.target) { + case GitHubTarget.DotCom: + useFlow &&= flow.options.supportsGitHubDotCom; + break; + case GitHubTarget.Enterprise: + useFlow &&= flow.options.supportsGitHubEnterpriseServer; + break; + case GitHubTarget.HostedEnterprise: + useFlow &&= flow.options.supportsHostedGitHubEnterprise; + break; + } + + switch (query.extensionHost) { + case ExtensionHost.Remote: + useFlow &&= flow.options.supportsRemoteExtensionHost; + break; + case ExtensionHost.WebWorker: + useFlow &&= flow.options.supportsWebWorkerExtensionHost; + break; + } + + if (!Config.gitHubClientSecret) { + useFlow &&= flow.options.supportsNoClientSecret; + } + + if (query.isSupportedClient) { + // TODO: revisit how we support PAT in GHES but not DotCom... but this works for now since + // there isn't another flow that has supportsSupportedClients = false + useFlow &&= (flow.options.supportsSupportedClients || query.target !== GitHubTarget.DotCom); + } else { + useFlow &&= flow.options.supportsUnsupportedClients; + } + return useFlow; + }); +} diff --git a/extensions/github-authentication/src/github.ts b/extensions/github-authentication/src/github.ts index 0588065eacd..c710cbe4f2f 100644 --- a/extensions/github-authentication/src/github.ts +++ b/extensions/github-authentication/src/github.ts @@ -7,10 +7,11 @@ import * as vscode from 'vscode'; import TelemetryReporter from '@vscode/extension-telemetry'; import { Keychain } from './common/keychain'; import { GitHubServer, IGitHubServer } from './githubServer'; -import { arrayEquals } from './common/utils'; +import { PromiseAdapter, arrayEquals, promiseFromEvent } from './common/utils'; import { ExperimentationTelemetry } from './common/experimentationService'; import { Log } from './common/logger'; import { crypto } from './node/crypto'; +import { TIMED_OUT_ERROR, USER_CANCELLATION_ERROR } from './common/errors'; interface SessionData { id: string; @@ -29,9 +30,63 @@ export enum AuthProviderType { } export class UriEventHandler extends vscode.EventEmitter implements vscode.UriHandler { + private readonly _pendingNonces = new Map(); + private readonly _codeExchangePromises = new Map; cancel: vscode.EventEmitter }>(); + public handleUri(uri: vscode.Uri) { this.fire(uri); } + + public async waitForCode(logger: Log, scopes: string, nonce: string, token: vscode.CancellationToken) { + const existingNonces = this._pendingNonces.get(scopes) || []; + this._pendingNonces.set(scopes, [...existingNonces, nonce]); + + let codeExchangePromise = this._codeExchangePromises.get(scopes); + if (!codeExchangePromise) { + codeExchangePromise = promiseFromEvent(this.event, this.handleEvent(logger, scopes)); + this._codeExchangePromises.set(scopes, codeExchangePromise); + } + + try { + return await Promise.race([ + codeExchangePromise.promise, + new Promise((_, reject) => setTimeout(() => reject(TIMED_OUT_ERROR), 300_000)), // 5min timeout + promiseFromEvent(token.onCancellationRequested, (_, __, reject) => { reject(USER_CANCELLATION_ERROR); }).promise + ]); + } finally { + this._pendingNonces.delete(scopes); + codeExchangePromise?.cancel.fire(); + this._codeExchangePromises.delete(scopes); + } + } + + private handleEvent: (logger: Log, scopes: string) => PromiseAdapter = + (logger: Log, scopes) => (uri, resolve, reject) => { + const query = new URLSearchParams(uri.query); + const code = query.get('code'); + const nonce = query.get('nonce'); + if (!code) { + reject(new Error('No code')); + return; + } + if (!nonce) { + reject(new Error('No nonce')); + return; + } + + const acceptedNonces = this._pendingNonces.get(scopes) || []; + if (!acceptedNonces.includes(nonce)) { + // A common scenario of this happening is if you: + // 1. Trigger a sign in with one set of scopes + // 2. Before finishing 1, you trigger a sign in with a different set of scopes + // In this scenario we should just return and wait for the next UriHandler event + // to run as we are probably still waiting on the user to hit 'Continue' + logger.info('Nonce not found in accepted nonces. Skipping this execution...'); + return; + } + + resolve(code); + }; } export class GitHubAuthenticationProvider implements vscode.AuthenticationProvider, vscode.Disposable { diff --git a/extensions/github-authentication/src/githubServer.ts b/extensions/github-authentication/src/githubServer.ts index 98bb1dd822f..7ac5cd8c577 100644 --- a/extensions/github-authentication/src/githubServer.ts +++ b/extensions/github-authentication/src/githubServer.ts @@ -4,26 +4,18 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; -import * as path from 'path'; -import { PromiseAdapter, promiseFromEvent } from './common/utils'; import { ExperimentationTelemetry } from './common/experimentationService'; import { AuthProviderType, UriEventHandler } from './github'; import { Log } from './common/logger'; import { isSupportedClient, isSupportedTarget } from './common/env'; -import { LoopbackAuthServer } from './node/authServer'; import { crypto } from './node/crypto'; import { fetching } from './node/fetch'; - -const CLIENT_ID = '01ab8ac9400c4e429b23'; -const GITHUB_TOKEN_URL = 'https://vscode.dev/codeExchangeProxyEndpoints/github/login/oauth/access_token'; +import { ExtensionHost, GitHubTarget, getFlows } from './flows'; +import { NETWORK_ERROR, USER_CANCELLATION_ERROR } from './common/errors'; // This is the error message that we throw if the login was cancelled for any reason. Extensions // calling `getSession` can handle this error to know that the user cancelled the login. const CANCELLATION_ERROR = 'Cancelled'; -// These error messages are internal and should not be shown to the user in any way. -const TIMED_OUT_ERROR = 'Timed out'; -const USER_CANCELLATION_ERROR = 'User Cancelled'; -const NETWORK_ERROR = 'network error'; const REDIRECT_URL_STABLE = 'https://vscode.dev/redirect'; const REDIRECT_URL_INSIDERS = 'https://insiders.vscode.dev/redirect'; @@ -35,41 +27,10 @@ export interface IGitHubServer { friendlyName: string; } -interface IGitHubDeviceCodeResponse { - device_code: string; - user_code: string; - verification_uri: string; - interval: number; -} - -async function getScopes(token: string, serverUri: vscode.Uri, logger: Log): Promise { - try { - logger.info('Getting token scopes...'); - const result = await fetching(serverUri.toString(), { - headers: { - Authorization: `token ${token}`, - 'User-Agent': `${vscode.env.appName} (${vscode.env.appHost})` - } - }); - - if (result.ok) { - const scopes = result.headers.get('X-OAuth-Scopes'); - return scopes ? scopes.split(',').map(scope => scope.trim()) : []; - } else { - logger.error(`Getting scopes failed: ${result.statusText}`); - throw new Error(result.statusText); - } - } catch (ex) { - logger.error(ex.message); - throw new Error(NETWORK_ERROR); - } -} export class GitHubServer implements IGitHubServer { readonly friendlyName: string; - private readonly _pendingNonces = new Map(); - private readonly _codeExchangePromises = new Map; cancel: vscode.EventEmitter }>(); private readonly _type: AuthProviderType; private _redirectEndpoint: string | undefined; @@ -148,48 +109,33 @@ export class GitHubServer implements IGitHubServer { const supportedClient = isSupportedClient(callbackUri); const supportedTarget = isSupportedTarget(this._type, this._ghesUri); - if (supportedClient && supportedTarget) { - try { - return await this.doLoginWithoutLocalServer(scopes, nonce, callbackUri); - } catch (e) { - this._logger.error(e); - userCancelled = e.message ?? e === USER_CANCELLATION_ERROR; - } - } - // Starting a local server is only supported if: - // 1. We are in a UI extension because we need to open a port on the machine that has the browser - // 2. We are in a node runtime because we need to open a port on the machine - // 3. code exchange can only be done with a supported target - if ( - this._extensionKind === vscode.ExtensionKind.UI && - typeof navigator === 'undefined' && - supportedTarget - ) { - try { - await promptToContinue(vscode.l10n.t('local server')); - return await this.doLoginWithLocalServer(scopes); - } catch (e) { - userCancelled = this.processLoginError(e); - } - } + const flows = getFlows({ + target: this._type === AuthProviderType.github + ? GitHubTarget.DotCom + : supportedTarget ? GitHubTarget.HostedEnterprise : GitHubTarget.Enterprise, + extensionHost: typeof navigator === 'undefined' + ? this._extensionKind === vscode.ExtensionKind.UI ? ExtensionHost.Local : ExtensionHost.Remote + : ExtensionHost.WebWorker, + isSupportedClient: supportedClient + }); - // We only can use the Device Code flow when we have a full node environment because of CORS. - if (typeof navigator === 'undefined') { - try { - await promptToContinue(vscode.l10n.t('device code')); - return await this.doLoginDeviceCodeFlow(scopes); - } catch (e) { - userCancelled = this.processLoginError(e); - } - } - // In a supported environment, we can't use PAT auth because we use this auth for Settings Sync and it doesn't support PATs. - // With that said, GitHub Enterprise isn't used by Settings Sync so we can use PATs for that. - if (!supportedClient || this._type === AuthProviderType.githubEnterprise) { + for (const flow of flows) { try { - await promptToContinue(vscode.l10n.t('personal access token')); - return await this.doLoginWithPat(scopes); + if (flow !== flows[0]) { + await promptToContinue(flow.label); + } + return await flow.trigger({ + scopes, + callbackUri, + nonce, + baseUri: this.baseUri, + logger: this._logger, + uriHandler: this._uriHandler, + enterpriseUri: this._ghesUri, + redirectUri: vscode.Uri.parse(await this.getRedirectEndpoint()), + }); } catch (e) { userCancelled = this.processLoginError(e); } @@ -198,301 +144,6 @@ export class GitHubServer implements IGitHubServer { throw new Error(userCancelled ? CANCELLATION_ERROR : 'No auth flow succeeded.'); } - private async doLoginWithoutLocalServer(scopes: string, nonce: string, callbackUri: vscode.Uri): Promise { - this._logger.info(`Trying without local server... (${scopes})`); - return await vscode.window.withProgress({ - location: vscode.ProgressLocation.Notification, - title: vscode.l10n.t({ - message: 'Signing in to {0}...', - args: [this.baseUri.authority], - comment: ['The {0} will be a url, e.g. github.com'] - }), - cancellable: true - }, async (_, token) => { - const existingNonces = this._pendingNonces.get(scopes) || []; - this._pendingNonces.set(scopes, [...existingNonces, nonce]); - const redirectUri = await this.getRedirectEndpoint(); - const searchParams = new URLSearchParams([ - ['client_id', CLIENT_ID], - ['redirect_uri', redirectUri], - ['scope', scopes], - ['state', encodeURIComponent(callbackUri.toString(true))] - ]); - - const uri = vscode.Uri.parse(this.baseUri.with({ - path: '/login/oauth/authorize', - query: searchParams.toString() - }).toString(true)); - await vscode.env.openExternal(uri); - - // Register a single listener for the URI callback, in case the user starts the login process multiple times - // before completing it. - let codeExchangePromise = this._codeExchangePromises.get(scopes); - if (!codeExchangePromise) { - codeExchangePromise = promiseFromEvent(this._uriHandler!.event, this.handleUri(scopes)); - this._codeExchangePromises.set(scopes, codeExchangePromise); - } - - try { - return await Promise.race([ - codeExchangePromise.promise, - new Promise((_, reject) => setTimeout(() => reject(TIMED_OUT_ERROR), 300_000)), // 5min timeout - promiseFromEvent(token.onCancellationRequested, (_, __, reject) => { reject(USER_CANCELLATION_ERROR); }).promise - ]); - } finally { - this._pendingNonces.delete(scopes); - codeExchangePromise?.cancel.fire(); - this._codeExchangePromises.delete(scopes); - } - }); - } - - private async doLoginWithLocalServer(scopes: string): Promise { - this._logger.info(`Trying with local server... (${scopes})`); - return await vscode.window.withProgress({ - location: vscode.ProgressLocation.Notification, - title: vscode.l10n.t({ - message: 'Signing in to {0}...', - args: [this.baseUri.authority], - comment: ['The {0} will be a url, e.g. github.com'] - }), - cancellable: true - }, async (_, token) => { - const redirectUri = await this.getRedirectEndpoint(); - const searchParams = new URLSearchParams([ - ['client_id', CLIENT_ID], - ['redirect_uri', redirectUri], - ['scope', scopes], - ]); - - const loginUrl = this.baseUri.with({ - path: '/login/oauth/authorize', - query: searchParams.toString() - }); - const server = new LoopbackAuthServer(path.join(__dirname, '../media'), loginUrl.toString(true)); - const port = await server.start(); - - let codeToExchange; - try { - vscode.env.openExternal(vscode.Uri.parse(`http://127.0.0.1:${port}/signin?nonce=${encodeURIComponent(server.nonce)}`)); - const { code } = await Promise.race([ - server.waitForOAuthResponse(), - new Promise((_, reject) => setTimeout(() => reject(TIMED_OUT_ERROR), 300_000)), // 5min timeout - promiseFromEvent(token.onCancellationRequested, (_, __, reject) => { reject(USER_CANCELLATION_ERROR); }).promise - ]); - codeToExchange = code; - } finally { - setTimeout(() => { - void server.stop(); - }, 5000); - } - - const accessToken = await this.exchangeCodeForToken(codeToExchange); - return accessToken; - }); - } - - private async doLoginDeviceCodeFlow(scopes: string): Promise { - this._logger.info(`Trying device code flow... (${scopes})`); - - // Get initial device code - const uri = this.baseUri.with({ - path: '/login/device/code', - query: `client_id=${CLIENT_ID}&scope=${scopes}` - }); - const result = await fetching(uri.toString(true), { - method: 'POST', - headers: { - Accept: 'application/json' - } - }); - if (!result.ok) { - throw new Error(`Failed to get one-time code: ${await result.text()}`); - } - - const json = await result.json() as IGitHubDeviceCodeResponse; - - const button = vscode.l10n.t('Copy & Continue to GitHub'); - const modalResult = await vscode.window.showInformationMessage( - vscode.l10n.t({ message: 'Your Code: {0}', args: [json.user_code], comment: ['The {0} will be a code, e.g. 123-456'] }), - { - modal: true, - detail: vscode.l10n.t('To finish authenticating, navigate to GitHub and paste in the above one-time code.') - }, button); - - if (modalResult !== button) { - throw new Error(USER_CANCELLATION_ERROR); - } - - await vscode.env.clipboard.writeText(json.user_code); - - const uriToOpen = await vscode.env.asExternalUri(vscode.Uri.parse(json.verification_uri)); - await vscode.env.openExternal(uriToOpen); - - return await this.waitForDeviceCodeAccessToken(json); - } - - private async doLoginWithPat(scopes: string): Promise { - this._logger.info(`Trying to retrieve PAT... (${scopes})`); - - const button = vscode.l10n.t('Continue to GitHub'); - const modalResult = await vscode.window.showInformationMessage( - vscode.l10n.t('Continue to GitHub to create a Personal Access Token (PAT)'), - { - modal: true, - detail: vscode.l10n.t('To finish authenticating, navigate to GitHub to create a PAT then paste the PAT into the input box.') - }, button); - - if (modalResult !== button) { - throw new Error(USER_CANCELLATION_ERROR); - } - - const description = `${vscode.env.appName} (${scopes})`; - const uriToOpen = await vscode.env.asExternalUri(this.baseUri.with({ path: '/settings/tokens/new', query: `description=${description}&scopes=${scopes.split(' ').join(',')}` })); - await vscode.env.openExternal(uriToOpen); - const token = await vscode.window.showInputBox({ placeHolder: `ghp_1a2b3c4...`, prompt: `GitHub Personal Access Token - ${scopes}`, ignoreFocusOut: true }); - if (!token) { throw new Error(USER_CANCELLATION_ERROR); } - - const tokenScopes = await getScopes(token, this.getServerUri('/'), this._logger); // Example: ['repo', 'user'] - const scopesList = scopes.split(' '); // Example: 'read:user repo user:email' - if (!scopesList.every(scope => { - const included = tokenScopes.includes(scope); - if (included || !scope.includes(':')) { - return included; - } - - return scope.split(':').some(splitScopes => { - return tokenScopes.includes(splitScopes); - }); - })) { - throw new Error(`The provided token does not match the requested scopes: ${scopes}`); - } - - return token; - } - - private async waitForDeviceCodeAccessToken( - json: IGitHubDeviceCodeResponse, - ): Promise { - return await vscode.window.withProgress({ - location: vscode.ProgressLocation.Notification, - cancellable: true, - title: vscode.l10n.t({ - message: 'Open [{0}]({0}) in a new tab and paste your one-time code: {1}', - args: [json.verification_uri, json.user_code], - comment: [ - 'The [{0}]({0}) will be a url and the {1} will be a code, e.g. 123-456', - '{Locked="[{0}]({0})"}' - ] - }) - }, async (_, token) => { - const refreshTokenUri = this.baseUri.with({ - path: '/login/oauth/access_token', - query: `client_id=${CLIENT_ID}&device_code=${json.device_code}&grant_type=urn:ietf:params:oauth:grant-type:device_code` - }); - - // Try for 2 minutes - const attempts = 120 / json.interval; - for (let i = 0; i < attempts; i++) { - await new Promise(resolve => setTimeout(resolve, json.interval * 1000)); - if (token.isCancellationRequested) { - throw new Error(USER_CANCELLATION_ERROR); - } - let accessTokenResult; - try { - accessTokenResult = await fetching(refreshTokenUri.toString(true), { - method: 'POST', - headers: { - Accept: 'application/json' - } - }); - } catch { - continue; - } - - if (!accessTokenResult.ok) { - continue; - } - - const accessTokenJson = await accessTokenResult.json(); - - if (accessTokenJson.error === 'authorization_pending') { - continue; - } - - if (accessTokenJson.error) { - throw new Error(accessTokenJson.error_description); - } - - return accessTokenJson.access_token; - } - - throw new Error(TIMED_OUT_ERROR); - }); - } - - private handleUri: (scopes: string) => PromiseAdapter = - (scopes) => (uri, resolve, reject) => { - const query = new URLSearchParams(uri.query); - const code = query.get('code'); - const nonce = query.get('nonce'); - if (!code) { - reject(new Error('No code')); - return; - } - if (!nonce) { - reject(new Error('No nonce')); - return; - } - - const acceptedNonces = this._pendingNonces.get(scopes) || []; - if (!acceptedNonces.includes(nonce)) { - // A common scenario of this happening is if you: - // 1. Trigger a sign in with one set of scopes - // 2. Before finishing 1, you trigger a sign in with a different set of scopes - // In this scenario we should just return and wait for the next UriHandler event - // to run as we are probably still waiting on the user to hit 'Continue' - this._logger.info('Nonce not found in accepted nonces. Skipping this execution...'); - return; - } - - resolve(this.exchangeCodeForToken(code)); - }; - - private async exchangeCodeForToken(code: string): Promise { - this._logger.info('Exchanging code for token...'); - - const proxyEndpoints: { [providerId: string]: string } | undefined = await vscode.commands.executeCommand('workbench.getCodeExchangeProxyEndpoints'); - const endpointUrl = proxyEndpoints?.github ? `${proxyEndpoints.github}login/oauth/access_token` : GITHUB_TOKEN_URL; - - const body = new URLSearchParams([['code', code]]); - if (this._type === AuthProviderType.githubEnterprise) { - body.append('github_enterprise', this.baseUri.toString(true)); - body.append('redirect_uri', await this.getRedirectEndpoint()); - } - const result = await fetching(endpointUrl, { - method: 'POST', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/x-www-form-urlencoded', - 'Content-Length': body.toString() - - }, - body: body.toString() - }); - - if (result.ok) { - const json = await result.json(); - this._logger.info('Token exchange success!'); - return json.access_token; - } else { - const text = await result.text(); - const error = new Error(text); - error.name = 'GitHubTokenExchangeError'; - throw error; - } - } - private getServerUri(path: string = '') { const apiUri = this.baseUri; // github.com and Hosted GitHub Enterprise instances