diff --git a/extensions/github-authentication/src/github.ts b/extensions/github-authentication/src/github.ts index 52865e6502e..f6f2301ab16 100644 --- a/extensions/github-authentication/src/github.ts +++ b/extensions/github-authentication/src/github.ts @@ -6,7 +6,7 @@ import * as vscode from 'vscode'; import { v4 as uuid } from 'uuid'; import { Keychain } from './common/keychain'; -import { GitHubEnterpriseServer, GitHubServer, IGitHubServer } from './githubServer'; +import { GitHubServer, IGitHubServer } from './githubServer'; import { arrayEquals } from './common/utils'; import { ExperimentationTelemetry } from './experimentationService'; import TelemetryReporter from '@vscode/extension-telemetry'; @@ -43,15 +43,12 @@ export class GitHubAuthenticationProvider implements vscode.AuthenticationProvid const { name, version, aiKey } = context.extension.packageJSON as { name: string; version: string; aiKey: string }; this._telemetryReporter = new ExperimentationTelemetry(context, new TelemetryReporter(name, version, aiKey)); - if (this.type === AuthProviderType.github) { - this._githubServer = new GitHubServer( - // We only can use the Device Code flow when we have a full node environment because of CORS. - context.extension.extensionKind === vscode.ExtensionKind.Workspace || vscode.env.uiKind === vscode.UIKind.Desktop, - this._logger, - this._telemetryReporter); - } else { - this._githubServer = new GitHubEnterpriseServer(this._logger, this._telemetryReporter); - } + this._githubServer = new GitHubServer( + this.type, + // We only can use the Device Code flow when we have a full node environment because of CORS. + context.extension.extensionKind === vscode.ExtensionKind.Workspace || vscode.env.uiKind === vscode.UIKind.Desktop, + this._logger, + this._telemetryReporter); // Contains the current state of the sessions we have available. this._sessionsPromise = this.readSessions().then((sessions) => { diff --git a/extensions/github-authentication/src/githubServer.ts b/extensions/github-authentication/src/githubServer.ts index 1614794fcb0..94ce5429e78 100644 --- a/extensions/github-authentication/src/githubServer.ts +++ b/extensions/github-authentication/src/githubServer.ts @@ -17,8 +17,6 @@ import path = require('path'); const localize = nls.loadMessageBundle(); const CLIENT_ID = '01ab8ac9400c4e429b23'; -const GITHUB_AUTHORIZE_URL = 'https://github.com/login/oauth/authorize'; -// TODO: change to stable when that happens const GITHUB_TOKEN_URL = 'https://vscode.dev/codeExchangeProxyEndpoints/github/login/oauth/access_token'; const NETWORK_ERROR = 'network error'; @@ -74,71 +72,77 @@ async function getScopes(token: string, serverUri: vscode.Uri, logger: Log): Pro } } -async function getUserInfo(token: string, serverUri: vscode.Uri, logger: Log): Promise<{ id: string; accountName: string }> { - let result: Response; - try { - logger.info('Getting user info...'); - result = await fetch(serverUri.toString(), { - headers: { - Authorization: `token ${token}`, - 'User-Agent': 'Visual-Studio-Code' - } - }); - } catch (ex) { - logger.error(ex.message); - throw new Error(NETWORK_ERROR); - } - - if (result.ok) { - try { - const json = await result.json(); - logger.info('Got account info!'); - return { id: json.id, accountName: json.login }; - } catch (e) { - logger.error(`Unexpected error parsing response from GitHub: ${e.message ?? e}`); - throw e; - } - } else { - // either display the response message or the http status text - let errorMessage = result.statusText; - try { - const json = await result.json(); - if (json.message) { - errorMessage = json.message; - } - } catch (err) { - // noop - } - logger.error(`Getting account info failed: ${errorMessage}`); - throw new Error(errorMessage); - } -} - export class GitHubServer implements IGitHubServer { - friendlyName = 'GitHub'; - type = AuthProviderType.github; + readonly friendlyName: string; private _pendingNonces = new Map(); private _codeExchangePromises = new Map; cancel: vscode.EventEmitter }>(); - private _disposable: vscode.Disposable; - private _uriHandler = new UriEventHandler(this._logger); - private readonly getRedirectEndpoint: Thenable; + private _disposable: vscode.Disposable | undefined; + private static _uriHandler: UriEventHandler | undefined; + private _redirectEndpoint: string | undefined; - constructor(private readonly _supportDeviceCodeFlow: boolean, private readonly _logger: Log, private readonly _telemetryReporter: ExperimentationTelemetry) { - this._disposable = vscode.window.registerUriHandler(this._uriHandler); + constructor( + public readonly type: AuthProviderType, + private readonly _supportDeviceCodeFlow: boolean, + private readonly _logger: Log, + private readonly _telemetryReporter: ExperimentationTelemetry + ) { + this.friendlyName = type === AuthProviderType.github ? 'GitHub' : 'GitHub Enterprise'; - this.getRedirectEndpoint = vscode.commands.executeCommand<{ [providerId: string]: string } | undefined>('workbench.getCodeExchangeProxyEndpoints').then((proxyEndpoints) => { + if (!GitHubServer._uriHandler) { + GitHubServer._uriHandler = new UriEventHandler(this._logger); + this._disposable = vscode.window.registerUriHandler(GitHubServer._uriHandler); + } + } + + get baseUri() { + if (this.type === AuthProviderType.github) { + return vscode.Uri.parse('https://github.com/'); + } + return vscode.Uri.parse(vscode.workspace.getConfiguration('github-enterprise').get('uri') || '', true); + } + + private async getRedirectEndpoint(): Promise { + if (this._redirectEndpoint) { + return this._redirectEndpoint; + } + if (this.type === AuthProviderType.github) { + const proxyEndpoints = await vscode.commands.executeCommand<{ [providerId: string]: string } | undefined>('workbench.getCodeExchangeProxyEndpoints'); // If we are running in insiders vscode.dev, then ensure we use the redirect route on that. - let redirectUri = REDIRECT_URL_STABLE; + this._redirectEndpoint = REDIRECT_URL_STABLE; if (proxyEndpoints?.github && new URL(proxyEndpoints.github).hostname === 'insiders.vscode.dev') { - redirectUri = REDIRECT_URL_INSIDERS; + this._redirectEndpoint = REDIRECT_URL_INSIDERS; } - return redirectUri; - }); + return this._redirectEndpoint; + } else { + // GHES + const result = await fetch(this.getServerUri('/meta').toString(true)); + if (result.ok) { + try { + const json: { installed_version: string } = await result.json(); + const [majorStr, minorStr, _patch] = json.installed_version.split('.'); + const major = Number(majorStr); + const minor = Number(minorStr); + if (major >= 4 || major === 3 && minor >= 8 + ) { + // GHES 3.8 and above used vscode.dev/redirect as the route. + // It only supports a single redirect endpoint, so we can't use + // insiders.vscode.dev/redirect when we're running in Insiders, unfortunately. + this._redirectEndpoint = 'https://vscode.dev/redirect'; + } + } catch (e) { + this._logger.error(e); + } + } + + // TODO in like 1 year change the default vscode.dev/redirect maybe + this._redirectEndpoint = 'https://vscode-auth.github.com/'; + } + return this._redirectEndpoint; } dispose() { - this._disposable.dispose(); + this._disposable?.dispose(); } // TODO@joaomoreno TODO@TylerLeonhardt @@ -217,26 +221,30 @@ export class GitHubServer implements IGitHubServer { this._logger.info(`Trying without local server... (${scopes})`); return await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, - title: localize('signingIn', "Signing in to github.com..."), + title: localize('signingIn', 'Signing in to {0}...', this.baseUri.authority), cancellable: true }, async (_, token) => { const existingNonces = this._pendingNonces.get(scopes) || []; this._pendingNonces.set(scopes, [...existingNonces, nonce]); - const redirectUri = await this.getRedirectEndpoint; + 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(`${GITHUB_AUTHORIZE_URL}?${searchParams.toString()}`); + + 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)); + codeExchangePromise = promiseFromEvent(GitHubServer._uriHandler!.event, this.handleUri(scopes)); this._codeExchangePromises.set(scopes, codeExchangePromise); } @@ -258,17 +266,21 @@ export class GitHubServer implements IGitHubServer { this._logger.info(`Trying with local server... (${scopes})`); return await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, - title: localize('signingInAnotherWay', "Signing in to github.com..."), + title: localize('signingInAnotherWay', "Signing in to {0}...", this.baseUri.authority), cancellable: true }, async (_, token) => { - const redirectUri = await this.getRedirectEndpoint; + const redirectUri = await this.getRedirectEndpoint(); const searchParams = new URLSearchParams([ ['client_id', CLIENT_ID], ['redirect_uri', redirectUri], ['scope', scopes], ]); - const loginUrl = `${GITHUB_AUTHORIZE_URL}?${searchParams.toString()}`; - const server = new LoopbackAuthServer(path.join(__dirname, '../media'), loginUrl); + + 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; @@ -295,8 +307,11 @@ export class GitHubServer implements IGitHubServer { this._logger.info(`Trying device code flow... (${scopes})`); // Get initial device code - const uri = `https://github.com/login/device/code?client_id=${CLIENT_ID}&scope=${scopes}`; - const result = await fetch(uri, { + const uri = this.baseUri.with({ + path: '/login/device/code', + query: `client_id=${CLIENT_ID}&scope=${scopes}` + }); + const result = await fetch(uri.toString(true), { method: 'POST', headers: { Accept: 'application/json' @@ -363,7 +378,10 @@ export class GitHubServer implements IGitHubServer { json.verification_uri, json.user_code) }, async (_, token) => { - const refreshTokenUri = `https://github.com/login/oauth/access_token?client_id=${CLIENT_ID}&device_code=${json.device_code}&grant_type=urn:ietf:params:oauth:grant-type:device_code`; + 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; @@ -374,7 +392,7 @@ export class GitHubServer implements IGitHubServer { } let accessTokenResult; try { - accessTokenResult = await fetch(refreshTokenUri, { + accessTokenResult = await fetch(refreshTokenUri.toString(true), { method: 'POST', headers: { Accept: 'application/json' @@ -439,7 +457,11 @@ export class GitHubServer implements IGitHubServer { 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 = `code=${code}`; + 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 fetch(endpointUrl, { method: 'POST', headers: { @@ -448,7 +470,7 @@ export class GitHubServer implements IGitHubServer { 'Content-Length': body.toString() }, - body + body: body.toString() }); if (result.ok) { @@ -464,12 +486,52 @@ export class GitHubServer implements IGitHubServer { } private getServerUri(path: string = '') { - const apiUri = vscode.Uri.parse('https://api.github.com'); - return vscode.Uri.parse(`${apiUri.scheme}://${apiUri.authority}${path}`); + if (this.type === AuthProviderType.github) { + return vscode.Uri.parse('https://api.github.com').with({ path }); + } + // GHES + const apiUri = vscode.Uri.parse(vscode.workspace.getConfiguration('github-enterprise').get('uri') || '', true); + return vscode.Uri.parse(`${apiUri.scheme}://${apiUri.authority}/api/v3${path}`); } - public getUserInfo(token: string): Promise<{ id: string; accountName: string }> { - return getUserInfo(token, this.getServerUri('/user'), this._logger); + public async getUserInfo(token: string): Promise<{ id: string; accountName: string }> { + let result: Response; + try { + this._logger.info('Getting user info...'); + result = await fetch(this.getServerUri('/user').toString(), { + headers: { + Authorization: `token ${token}`, + 'User-Agent': 'Visual-Studio-Code' + } + }); + } catch (ex) { + this._logger.error(ex.message); + throw new Error(NETWORK_ERROR); + } + + if (result.ok) { + try { + const json = await result.json(); + this._logger.info('Got account info!'); + return { id: json.id, accountName: json.login }; + } catch (e) { + this._logger.error(`Unexpected error parsing response from GitHub: ${e.message ?? e}`); + throw e; + } + } else { + // either display the response message or the http status text + let errorMessage = result.statusText; + try { + const json = await result.json(); + if (json.message) { + errorMessage = json.message; + } + } catch (err) { + // noop + } + this._logger.error(`Getting account info failed: ${errorMessage}`); + throw new Error(errorMessage); + } } public async sendAdditionalTelemetryInfo(token: string): Promise { @@ -482,6 +544,15 @@ export class GitHubServer implements IGitHubServer { return; } + if (this.type === AuthProviderType.github) { + return await this.checkEduDetails(token); + } + + // GHES + await this.checkEnterpriseVersion(token); + } + + private async checkEduDetails(token: string): Promise { try { const result = await fetch('https://education.github.com/api/user', { headers: { @@ -513,7 +584,7 @@ export class GitHubServer implements IGitHubServer { } } - public async checkEnterpriseVersion(token: string): Promise { + private async checkEnterpriseVersion(token: string): Promise { try { const result = await fetch(this.getServerUri('/meta').toString(), { @@ -543,75 +614,3 @@ export class GitHubServer implements IGitHubServer { } } } - -export class GitHubEnterpriseServer implements IGitHubServer { - friendlyName = 'GitHub Enterprise'; - type = AuthProviderType.githubEnterprise; - - constructor(private readonly _logger: Log, private readonly telemetryReporter: ExperimentationTelemetry) { } - - dispose() { } - - public async login(scopes: string): Promise { - this._logger.info(`Logging in for the following scopes: ${scopes}`); - - const token = await vscode.window.showInputBox({ prompt: 'GitHub Personal Access Token', ignoreFocusOut: true }); - if (!token) { throw new Error('Sign in failed: No token provided'); } - - 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 getServerUri(path: string = '') { - const apiUri = vscode.Uri.parse(vscode.workspace.getConfiguration('github-enterprise').get('uri') || '', true); - return vscode.Uri.parse(`${apiUri.scheme}://${apiUri.authority}/api/v3${path}`); - } - - public async getUserInfo(token: string): Promise<{ id: string; accountName: string }> { - return getUserInfo(token, this.getServerUri('/user'), this._logger); - } - - public async sendAdditionalTelemetryInfo(token: string): Promise { - try { - - const result = await fetch(this.getServerUri('/meta').toString(), { - headers: { - Authorization: `token ${token}`, - 'User-Agent': 'Visual-Studio-Code' - } - }); - - if (!result.ok) { - return; - } - - const json: { verifiable_password_authentication: boolean; installed_version: string } = await result.json(); - - /* __GDPR__ - "ghe-session" : { - "owner": "TylerLeonhardt", - "version": { "classification": "SystemMetaData", "purpose": "FeatureInsight" } - } - */ - this.telemetryReporter.sendTelemetryEvent('ghe-session', { - version: json.installed_version - }); - } catch { - // No-op - } - } -}