Path: blob/main/extensions/github-authentication/src/githubServer.ts
3316 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,181expectJSON: false,182method: 'DELETE',183headers: {184Accept: 'application/vnd.github+json',185Authorization: authHeader,186'X-GitHub-Api-Version': '2022-11-28',187'User-Agent': `${vscode.env.appName} (${vscode.env.appHost})`188},189body: JSON.stringify({ access_token: session.accessToken }),190});191192if (result.status === 204) {193this._logger.trace(`Successfully deleted token from session (${session.id}) from server.`);194return;195}196197try {198const body = await result.text();199throw new Error(body);200} catch (e) {201throw new Error(`${result.status} ${result.statusText}`);202}203} catch (e) {204this._logger.warn('Failed to delete token from server.' + (e.message ?? e));205}206}207208private getServerUri(path: string = '') {209const apiUri = this.baseUri;210// github.com and Hosted GitHub Enterprise instances211if (isSupportedTarget(this._type, this._ghesUri)) {212return vscode.Uri.parse(`${apiUri.scheme}://api.${apiUri.authority}`).with({ path });213}214// GitHub Enterprise Server (aka on-prem)215return vscode.Uri.parse(`${apiUri.scheme}://${apiUri.authority}/api/v3${path}`);216}217218public async getUserInfo(token: string): Promise<{ id: string; accountName: string }> {219let result;220try {221this._logger.info('Getting user info...');222result = await fetching(this.getServerUri('/user').toString(), {223logger: this._logger,224expectJSON: true,225headers: {226Authorization: `token ${token}`,227'User-Agent': `${vscode.env.appName} (${vscode.env.appHost})`228}229});230} catch (ex) {231this._logger.error(ex.message);232throw new Error(NETWORK_ERROR);233}234235if (result.ok) {236try {237const json = await result.json() as { id: number; login: string };238this._logger.info('Got account info!');239return { id: `${json.id}`, accountName: json.login };240} catch (e) {241this._logger.error(`Unexpected error parsing response from GitHub: ${e.message ?? e}`);242throw e;243}244} else {245// either display the response message or the http status text246let errorMessage = result.statusText;247try {248const json = await result.json();249if (json.message) {250errorMessage = json.message;251}252} catch (err) {253// noop254}255this._logger.error(`Getting account info failed: ${errorMessage}`);256throw new Error(errorMessage);257}258}259260public async sendAdditionalTelemetryInfo(session: vscode.AuthenticationSession): Promise<void> {261if (!vscode.env.isTelemetryEnabled) {262return;263}264const nocors = await this.isNoCorsEnvironment();265266if (nocors) {267return;268}269270if (this._type === AuthProviderType.github) {271return await this.checkUserDetails(session);272}273274// GHES275await this.checkEnterpriseVersion(session.accessToken);276}277278private async checkUserDetails(session: vscode.AuthenticationSession): Promise<void> {279let edu: string | undefined;280281try {282const result = await fetching('https://education.github.com/api/user', {283logger: this._logger,284expectJSON: true,285headers: {286Authorization: `token ${session.accessToken}`,287'faculty-check-preview': 'true',288'User-Agent': `${vscode.env.appName} (${vscode.env.appHost})`289}290});291292if (result.ok) {293const json: { student: boolean; faculty: boolean } = await result.json();294edu = json.student295? 'student'296: json.faculty297? 'faculty'298: 'none';299} else {300edu = 'unknown';301}302} catch (e) {303edu = 'unknown';304}305306/* __GDPR__307"session" : {308"owner": "TylerLeonhardt",309"isEdu": { "classification": "SystemMetaData", "purpose": "FeatureInsight" },310"isManaged": { "classification": "SystemMetaData", "purpose": "FeatureInsight" }311}312*/313this._telemetryReporter.sendTelemetryEvent('session', {314isEdu: edu,315// Apparently, this is how you tell if a user is an EMU...316isManaged: session.account.label.includes('_') ? 'true' : 'false'317});318}319320private async checkEnterpriseVersion(token: string): Promise<void> {321try {322let version: string;323if (!isSupportedTarget(this._type, this._ghesUri)) {324const result = await fetching(this.getServerUri('/meta').toString(), {325logger: this._logger,326expectJSON: true,327headers: {328Authorization: `token ${token}`,329'User-Agent': `${vscode.env.appName} (${vscode.env.appHost})`330}331});332333if (!result.ok) {334return;335}336337const json: { verifiable_password_authentication: boolean; installed_version: string } = await result.json();338version = json.installed_version;339} else {340version = 'hosted';341}342343/* __GDPR__344"ghe-session" : {345"owner": "TylerLeonhardt",346"version": { "classification": "SystemMetaData", "purpose": "FeatureInsight" }347}348*/349this._telemetryReporter.sendTelemetryEvent('ghe-session', {350version351});352} catch {353// No-op354}355}356357private processLoginError(error: Error): boolean {358if (error.message === CANCELLATION_ERROR) {359throw error;360}361this._logger.error(error.message ?? error);362return error.message === USER_CANCELLATION_ERROR;363}364}365366367