Path: blob/main/extensions/github-authentication/src/githubServer.ts
5222 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import * as vscode from 'vscode';6import { ExperimentationTelemetry } from './common/experimentationService';7import { AuthProviderType, UriEventHandler } from './github';8import { Log } from './common/logger';9import { isSupportedClient, isSupportedTarget } from './common/env';10import { crypto } from './node/crypto';11import { fetching } from './node/fetch';12import { ExtensionHost, GitHubSocialSignInProvider, GitHubTarget, getFlows } from './flows';13import { CANCELLATION_ERROR, NETWORK_ERROR, USER_CANCELLATION_ERROR } from './common/errors';14import { Config } from './config';15import { base64Encode } from './node/buffer';1617const REDIRECT_URL_STABLE = 'https://vscode.dev/redirect';18const REDIRECT_URL_INSIDERS = 'https://insiders.vscode.dev/redirect';1920export interface IGitHubServer {21login(scopes: string, signInProvider?: GitHubSocialSignInProvider, extraAuthorizeParameters?: Record<string, string>, existingLogin?: string): Promise<string>;22logout(session: vscode.AuthenticationSession): Promise<void>;23getUserInfo(token: string): Promise<{ id: string; accountName: string }>;24sendAdditionalTelemetryInfo(session: vscode.AuthenticationSession): Promise<void>;25friendlyName: string;26}272829export class GitHubServer implements IGitHubServer {30readonly friendlyName: string;3132private readonly _type: AuthProviderType;3334private _redirectEndpoint: string | undefined;3536constructor(37private readonly _logger: Log,38private readonly _telemetryReporter: ExperimentationTelemetry,39private readonly _uriHandler: UriEventHandler,40private readonly _extensionKind: vscode.ExtensionKind,41private readonly _ghesUri?: vscode.Uri42) {43this._type = _ghesUri ? AuthProviderType.githubEnterprise : AuthProviderType.github;44this.friendlyName = this._type === AuthProviderType.github ? 'GitHub' : _ghesUri?.authority!;45}4647get baseUri() {48if (this._type === AuthProviderType.github) {49return vscode.Uri.parse('https://github.com/');50}51return this._ghesUri!;52}5354private async getRedirectEndpoint(): Promise<string> {55if (this._redirectEndpoint) {56return this._redirectEndpoint;57}58if (this._type === AuthProviderType.github) {59const proxyEndpoints = await vscode.commands.executeCommand<{ [providerId: string]: string } | undefined>('workbench.getCodeExchangeProxyEndpoints');60// If we are running in insiders vscode.dev, then ensure we use the redirect route on that.61this._redirectEndpoint = REDIRECT_URL_STABLE;62if (proxyEndpoints?.github && new URL(proxyEndpoints.github).hostname === 'insiders.vscode.dev') {63this._redirectEndpoint = REDIRECT_URL_INSIDERS;64}65} else {66// GHE only supports a single redirect endpoint, so we can't use67// insiders.vscode.dev/redirect when we're running in Insiders, unfortunately.68// Additionally, we make the assumption that this function will only be used69// in flows that target supported GHE targets, not on-prem GHES. Because of this70// assumption, we can assume that the GHE version used is at least 3.8 which is71// the version that changed the redirect endpoint to this URI from the old72// GitHub maintained server.73this._redirectEndpoint = 'https://vscode.dev/redirect';74}75return this._redirectEndpoint;76}7778// TODO@joaomoreno TODO@TylerLeonhardt79private _isNoCorsEnvironment: boolean | undefined;80private async isNoCorsEnvironment(): Promise<boolean> {81if (this._isNoCorsEnvironment !== undefined) {82return this._isNoCorsEnvironment;83}84const uri = await vscode.env.asExternalUri(vscode.Uri.parse(`${vscode.env.uriScheme}://vscode.github-authentication/dummy`));85this._isNoCorsEnvironment = (uri.scheme === 'https' && /^((insiders\.)?vscode|github)\./.test(uri.authority)) || (uri.scheme === 'http' && /^localhost/.test(uri.authority));86return this._isNoCorsEnvironment;87}8889public async login(scopes: string, signInProvider?: GitHubSocialSignInProvider, extraAuthorizeParameters?: Record<string, string>, existingLogin?: string): Promise<string> {90this._logger.info(`Logging in for the following scopes: ${scopes}`);9192// Used for showing a friendlier message to the user when the explicitly cancel a flow.93let userCancelled: boolean | undefined;94const yes = vscode.l10n.t('Yes');95const no = vscode.l10n.t('No');96const promptToContinue = async (mode: string) => {97if (userCancelled === undefined) {98// We haven't had a failure yet so wait to prompt99return;100}101const message = userCancelled102? vscode.l10n.t('Having trouble logging in? Would you like to try a different way? ({0})', mode)103: vscode.l10n.t('You have not yet finished authorizing this extension to use GitHub. Would you like to try a different way? ({0})', mode);104const result = await vscode.window.showWarningMessage(message, yes, no);105if (result !== yes) {106throw new Error(CANCELLATION_ERROR);107}108};109110const nonce: string = crypto.getRandomValues(new Uint32Array(2)).reduce((prev, curr) => prev += curr.toString(16), '');111const callbackUri = await vscode.env.asExternalUri(vscode.Uri.parse(`${vscode.env.uriScheme}://vscode.github-authentication/did-authenticate?nonce=${encodeURIComponent(nonce)}`));112113const supportedClient = isSupportedClient(callbackUri);114const supportedTarget = isSupportedTarget(this._type, this._ghesUri);115116const isNodeEnvironment = typeof process !== 'undefined' && typeof process?.versions?.node === 'string';117const flows = getFlows({118target: this._type === AuthProviderType.github119? GitHubTarget.DotCom120: supportedTarget ? GitHubTarget.HostedEnterprise : GitHubTarget.Enterprise,121extensionHost: isNodeEnvironment122? this._extensionKind === vscode.ExtensionKind.UI ? ExtensionHost.Local : ExtensionHost.Remote123: ExtensionHost.WebWorker,124isSupportedClient: supportedClient125});126127128for (const flow of flows) {129try {130if (flow !== flows[0]) {131await promptToContinue(flow.label);132}133return await flow.trigger({134scopes,135callbackUri,136nonce,137signInProvider,138extraAuthorizeParameters,139baseUri: this.baseUri,140logger: this._logger,141uriHandler: this._uriHandler,142enterpriseUri: this._ghesUri,143redirectUri: vscode.Uri.parse(await this.getRedirectEndpoint()),144existingLogin145});146} catch (e) {147userCancelled = this.processLoginError(e);148}149}150151throw new Error(userCancelled ? CANCELLATION_ERROR : 'No auth flow succeeded.');152}153154public async logout(session: vscode.AuthenticationSession): Promise<void> {155this._logger.trace(`Deleting session (${session.id}) from server...`);156157if (!Config.gitHubClientSecret) {158this._logger.warn('No client secret configured for GitHub authentication. The token has been deleted with best effort on this system, but we are unable to delete the token on server without the client secret.');159return;160}161162// Only attempt to delete OAuth tokens. They are always prefixed with `gho_`.163// https://docs.github.com/en/rest/apps/oauth-applications#about-oauth-apps-and-oauth-authorizations-of-github-apps164if (!session.accessToken.startsWith('gho_')) {165this._logger.warn('The token being deleted is not an OAuth token. It has been deleted locally, but we cannot delete it on server.');166return;167}168169if (!isSupportedTarget(this._type, this._ghesUri)) {170this._logger.trace('GitHub.com and GitHub hosted GitHub Enterprise are the only options that support deleting tokens on the server. Skipping.');171return;172}173174const authHeader = 'Basic ' + base64Encode(`${Config.gitHubClientId}:${Config.gitHubClientSecret}`);175const uri = this.getServerUri(`/applications/${Config.gitHubClientId}/token`);176177try {178// Defined here: https://docs.github.com/en/rest/apps/oauth-applications?apiVersion=2022-11-28#delete-an-app-token179const result = await fetching(uri.toString(true), {180logger: this._logger,181retryFallbacks: true,182expectJSON: false,183method: 'DELETE',184headers: {185Accept: 'application/vnd.github+json',186Authorization: authHeader,187'X-GitHub-Api-Version': '2022-11-28',188'User-Agent': `${vscode.env.appName} (${vscode.env.appHost})`189},190body: JSON.stringify({ access_token: session.accessToken }),191});192193if (result.status === 204) {194this._logger.trace(`Successfully deleted token from session (${session.id}) from server.`);195return;196}197198try {199const body = await result.text();200throw new Error(body);201} catch (e) {202throw new Error(`${result.status} ${result.statusText}`);203}204} catch (e) {205this._logger.warn('Failed to delete token from server.' + (e.message ?? e));206}207}208209private getServerUri(path: string = '') {210const apiUri = this.baseUri;211// github.com and Hosted GitHub Enterprise instances212if (isSupportedTarget(this._type, this._ghesUri)) {213return vscode.Uri.parse(`${apiUri.scheme}://api.${apiUri.authority}`).with({ path });214}215// GitHub Enterprise Server (aka on-prem)216return vscode.Uri.parse(`${apiUri.scheme}://${apiUri.authority}/api/v3${path}`);217}218219public async getUserInfo(token: string): Promise<{ id: string; accountName: string }> {220let result;221try {222this._logger.info('Getting user info...');223result = await fetching(this.getServerUri('/user').toString(), {224logger: this._logger,225retryFallbacks: true,226expectJSON: true,227headers: {228Authorization: `token ${token}`,229'User-Agent': `${vscode.env.appName} (${vscode.env.appHost})`230}231});232} catch (ex) {233this._logger.error(ex.message);234throw new Error(NETWORK_ERROR);235}236237if (result.ok) {238try {239const json = await result.json() as { id: number; login: string };240this._logger.info('Got account info!');241return { id: `${json.id}`, accountName: json.login };242} catch (e) {243this._logger.error(`Unexpected error parsing response from GitHub: ${e.message ?? e}`);244throw e;245}246} else {247// either display the response message or the http status text248let errorMessage = result.statusText;249try {250const json = await result.json();251if (json.message) {252errorMessage = json.message;253}254} catch (err) {255// noop256}257this._logger.error(`Getting account info failed: ${errorMessage}`);258throw new Error(errorMessage);259}260}261262public async sendAdditionalTelemetryInfo(session: vscode.AuthenticationSession): Promise<void> {263if (!vscode.env.isTelemetryEnabled) {264return;265}266const nocors = await this.isNoCorsEnvironment();267268if (nocors) {269return;270}271272if (this._type === AuthProviderType.github) {273return await this.checkUserDetails(session);274}275276// GHES277await this.checkEnterpriseVersion(session.accessToken);278}279280private async checkUserDetails(session: vscode.AuthenticationSession): Promise<void> {281let edu: string | undefined;282283try {284const result = await fetching('https://education.github.com/api/user', {285logger: this._logger,286retryFallbacks: true,287expectJSON: true,288headers: {289Authorization: `token ${session.accessToken}`,290'faculty-check-preview': 'true',291'User-Agent': `${vscode.env.appName} (${vscode.env.appHost})`292}293});294295if (result.ok) {296const json: { student: boolean; faculty: boolean } = await result.json();297edu = json.student298? 'student'299: json.faculty300? 'faculty'301: 'none';302} else {303this._logger.info(`Unable to resolve optional EDU details. Status: ${result.status} ${result.statusText}`);304edu = 'unknown';305}306} catch (e) {307this._logger.info(`Unable to resolve optional EDU details. Error: ${e}`);308edu = 'unknown';309}310311/* __GDPR__312"session" : {313"owner": "TylerLeonhardt",314"isEdu": { "classification": "SystemMetaData", "purpose": "FeatureInsight" },315"isManaged": { "classification": "SystemMetaData", "purpose": "FeatureInsight" }316}317*/318this._telemetryReporter.sendTelemetryEvent('session', {319isEdu: edu,320// Apparently, this is how you tell if a user is an EMU...321isManaged: session.account.label.includes('_') ? 'true' : 'false'322});323}324325private async checkEnterpriseVersion(token: string): Promise<void> {326try {327let version: string;328if (!isSupportedTarget(this._type, this._ghesUri)) {329const result = await fetching(this.getServerUri('/meta').toString(), {330logger: this._logger,331retryFallbacks: true,332expectJSON: true,333headers: {334Authorization: `token ${token}`,335'User-Agent': `${vscode.env.appName} (${vscode.env.appHost})`336}337});338339if (!result.ok) {340return;341}342343const json: { verifiable_password_authentication: boolean; installed_version: string } = await result.json();344version = json.installed_version;345} else {346version = 'hosted';347}348349/* __GDPR__350"ghe-session" : {351"owner": "TylerLeonhardt",352"version": { "classification": "SystemMetaData", "purpose": "FeatureInsight" }353}354*/355this._telemetryReporter.sendTelemetryEvent('ghe-session', {356version357});358} catch {359// No-op360}361}362363private processLoginError(error: Error): boolean {364if (error.message === CANCELLATION_ERROR) {365throw error;366}367this._logger.error(error.message ?? error);368return error.message === USER_CANCELLATION_ERROR;369}370}371372373