Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/github-authentication/src/githubServer.ts
5222 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import * as vscode from 'vscode';
7
import { ExperimentationTelemetry } from './common/experimentationService';
8
import { AuthProviderType, UriEventHandler } from './github';
9
import { Log } from './common/logger';
10
import { isSupportedClient, isSupportedTarget } from './common/env';
11
import { crypto } from './node/crypto';
12
import { fetching } from './node/fetch';
13
import { ExtensionHost, GitHubSocialSignInProvider, GitHubTarget, getFlows } from './flows';
14
import { CANCELLATION_ERROR, NETWORK_ERROR, USER_CANCELLATION_ERROR } from './common/errors';
15
import { Config } from './config';
16
import { base64Encode } from './node/buffer';
17
18
const REDIRECT_URL_STABLE = 'https://vscode.dev/redirect';
19
const REDIRECT_URL_INSIDERS = 'https://insiders.vscode.dev/redirect';
20
21
export interface IGitHubServer {
22
login(scopes: string, signInProvider?: GitHubSocialSignInProvider, extraAuthorizeParameters?: Record<string, string>, existingLogin?: string): Promise<string>;
23
logout(session: vscode.AuthenticationSession): Promise<void>;
24
getUserInfo(token: string): Promise<{ id: string; accountName: string }>;
25
sendAdditionalTelemetryInfo(session: vscode.AuthenticationSession): Promise<void>;
26
friendlyName: string;
27
}
28
29
30
export class GitHubServer implements IGitHubServer {
31
readonly friendlyName: string;
32
33
private readonly _type: AuthProviderType;
34
35
private _redirectEndpoint: string | undefined;
36
37
constructor(
38
private readonly _logger: Log,
39
private readonly _telemetryReporter: ExperimentationTelemetry,
40
private readonly _uriHandler: UriEventHandler,
41
private readonly _extensionKind: vscode.ExtensionKind,
42
private readonly _ghesUri?: vscode.Uri
43
) {
44
this._type = _ghesUri ? AuthProviderType.githubEnterprise : AuthProviderType.github;
45
this.friendlyName = this._type === AuthProviderType.github ? 'GitHub' : _ghesUri?.authority!;
46
}
47
48
get baseUri() {
49
if (this._type === AuthProviderType.github) {
50
return vscode.Uri.parse('https://github.com/');
51
}
52
return this._ghesUri!;
53
}
54
55
private async getRedirectEndpoint(): Promise<string> {
56
if (this._redirectEndpoint) {
57
return this._redirectEndpoint;
58
}
59
if (this._type === AuthProviderType.github) {
60
const proxyEndpoints = await vscode.commands.executeCommand<{ [providerId: string]: string } | undefined>('workbench.getCodeExchangeProxyEndpoints');
61
// If we are running in insiders vscode.dev, then ensure we use the redirect route on that.
62
this._redirectEndpoint = REDIRECT_URL_STABLE;
63
if (proxyEndpoints?.github && new URL(proxyEndpoints.github).hostname === 'insiders.vscode.dev') {
64
this._redirectEndpoint = REDIRECT_URL_INSIDERS;
65
}
66
} else {
67
// GHE only supports a single redirect endpoint, so we can't use
68
// insiders.vscode.dev/redirect when we're running in Insiders, unfortunately.
69
// Additionally, we make the assumption that this function will only be used
70
// in flows that target supported GHE targets, not on-prem GHES. Because of this
71
// assumption, we can assume that the GHE version used is at least 3.8 which is
72
// the version that changed the redirect endpoint to this URI from the old
73
// GitHub maintained server.
74
this._redirectEndpoint = 'https://vscode.dev/redirect';
75
}
76
return this._redirectEndpoint;
77
}
78
79
// TODO@joaomoreno TODO@TylerLeonhardt
80
private _isNoCorsEnvironment: boolean | undefined;
81
private async isNoCorsEnvironment(): Promise<boolean> {
82
if (this._isNoCorsEnvironment !== undefined) {
83
return this._isNoCorsEnvironment;
84
}
85
const uri = await vscode.env.asExternalUri(vscode.Uri.parse(`${vscode.env.uriScheme}://vscode.github-authentication/dummy`));
86
this._isNoCorsEnvironment = (uri.scheme === 'https' && /^((insiders\.)?vscode|github)\./.test(uri.authority)) || (uri.scheme === 'http' && /^localhost/.test(uri.authority));
87
return this._isNoCorsEnvironment;
88
}
89
90
public async login(scopes: string, signInProvider?: GitHubSocialSignInProvider, extraAuthorizeParameters?: Record<string, string>, existingLogin?: string): Promise<string> {
91
this._logger.info(`Logging in for the following scopes: ${scopes}`);
92
93
// Used for showing a friendlier message to the user when the explicitly cancel a flow.
94
let userCancelled: boolean | undefined;
95
const yes = vscode.l10n.t('Yes');
96
const no = vscode.l10n.t('No');
97
const promptToContinue = async (mode: string) => {
98
if (userCancelled === undefined) {
99
// We haven't had a failure yet so wait to prompt
100
return;
101
}
102
const message = userCancelled
103
? vscode.l10n.t('Having trouble logging in? Would you like to try a different way? ({0})', mode)
104
: vscode.l10n.t('You have not yet finished authorizing this extension to use GitHub. Would you like to try a different way? ({0})', mode);
105
const result = await vscode.window.showWarningMessage(message, yes, no);
106
if (result !== yes) {
107
throw new Error(CANCELLATION_ERROR);
108
}
109
};
110
111
const nonce: string = crypto.getRandomValues(new Uint32Array(2)).reduce((prev, curr) => prev += curr.toString(16), '');
112
const callbackUri = await vscode.env.asExternalUri(vscode.Uri.parse(`${vscode.env.uriScheme}://vscode.github-authentication/did-authenticate?nonce=${encodeURIComponent(nonce)}`));
113
114
const supportedClient = isSupportedClient(callbackUri);
115
const supportedTarget = isSupportedTarget(this._type, this._ghesUri);
116
117
const isNodeEnvironment = typeof process !== 'undefined' && typeof process?.versions?.node === 'string';
118
const flows = getFlows({
119
target: this._type === AuthProviderType.github
120
? GitHubTarget.DotCom
121
: supportedTarget ? GitHubTarget.HostedEnterprise : GitHubTarget.Enterprise,
122
extensionHost: isNodeEnvironment
123
? this._extensionKind === vscode.ExtensionKind.UI ? ExtensionHost.Local : ExtensionHost.Remote
124
: ExtensionHost.WebWorker,
125
isSupportedClient: supportedClient
126
});
127
128
129
for (const flow of flows) {
130
try {
131
if (flow !== flows[0]) {
132
await promptToContinue(flow.label);
133
}
134
return await flow.trigger({
135
scopes,
136
callbackUri,
137
nonce,
138
signInProvider,
139
extraAuthorizeParameters,
140
baseUri: this.baseUri,
141
logger: this._logger,
142
uriHandler: this._uriHandler,
143
enterpriseUri: this._ghesUri,
144
redirectUri: vscode.Uri.parse(await this.getRedirectEndpoint()),
145
existingLogin
146
});
147
} catch (e) {
148
userCancelled = this.processLoginError(e);
149
}
150
}
151
152
throw new Error(userCancelled ? CANCELLATION_ERROR : 'No auth flow succeeded.');
153
}
154
155
public async logout(session: vscode.AuthenticationSession): Promise<void> {
156
this._logger.trace(`Deleting session (${session.id}) from server...`);
157
158
if (!Config.gitHubClientSecret) {
159
this._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.');
160
return;
161
}
162
163
// Only attempt to delete OAuth tokens. They are always prefixed with `gho_`.
164
// https://docs.github.com/en/rest/apps/oauth-applications#about-oauth-apps-and-oauth-authorizations-of-github-apps
165
if (!session.accessToken.startsWith('gho_')) {
166
this._logger.warn('The token being deleted is not an OAuth token. It has been deleted locally, but we cannot delete it on server.');
167
return;
168
}
169
170
if (!isSupportedTarget(this._type, this._ghesUri)) {
171
this._logger.trace('GitHub.com and GitHub hosted GitHub Enterprise are the only options that support deleting tokens on the server. Skipping.');
172
return;
173
}
174
175
const authHeader = 'Basic ' + base64Encode(`${Config.gitHubClientId}:${Config.gitHubClientSecret}`);
176
const uri = this.getServerUri(`/applications/${Config.gitHubClientId}/token`);
177
178
try {
179
// Defined here: https://docs.github.com/en/rest/apps/oauth-applications?apiVersion=2022-11-28#delete-an-app-token
180
const result = await fetching(uri.toString(true), {
181
logger: this._logger,
182
retryFallbacks: true,
183
expectJSON: false,
184
method: 'DELETE',
185
headers: {
186
Accept: 'application/vnd.github+json',
187
Authorization: authHeader,
188
'X-GitHub-Api-Version': '2022-11-28',
189
'User-Agent': `${vscode.env.appName} (${vscode.env.appHost})`
190
},
191
body: JSON.stringify({ access_token: session.accessToken }),
192
});
193
194
if (result.status === 204) {
195
this._logger.trace(`Successfully deleted token from session (${session.id}) from server.`);
196
return;
197
}
198
199
try {
200
const body = await result.text();
201
throw new Error(body);
202
} catch (e) {
203
throw new Error(`${result.status} ${result.statusText}`);
204
}
205
} catch (e) {
206
this._logger.warn('Failed to delete token from server.' + (e.message ?? e));
207
}
208
}
209
210
private getServerUri(path: string = '') {
211
const apiUri = this.baseUri;
212
// github.com and Hosted GitHub Enterprise instances
213
if (isSupportedTarget(this._type, this._ghesUri)) {
214
return vscode.Uri.parse(`${apiUri.scheme}://api.${apiUri.authority}`).with({ path });
215
}
216
// GitHub Enterprise Server (aka on-prem)
217
return vscode.Uri.parse(`${apiUri.scheme}://${apiUri.authority}/api/v3${path}`);
218
}
219
220
public async getUserInfo(token: string): Promise<{ id: string; accountName: string }> {
221
let result;
222
try {
223
this._logger.info('Getting user info...');
224
result = await fetching(this.getServerUri('/user').toString(), {
225
logger: this._logger,
226
retryFallbacks: true,
227
expectJSON: true,
228
headers: {
229
Authorization: `token ${token}`,
230
'User-Agent': `${vscode.env.appName} (${vscode.env.appHost})`
231
}
232
});
233
} catch (ex) {
234
this._logger.error(ex.message);
235
throw new Error(NETWORK_ERROR);
236
}
237
238
if (result.ok) {
239
try {
240
const json = await result.json() as { id: number; login: string };
241
this._logger.info('Got account info!');
242
return { id: `${json.id}`, accountName: json.login };
243
} catch (e) {
244
this._logger.error(`Unexpected error parsing response from GitHub: ${e.message ?? e}`);
245
throw e;
246
}
247
} else {
248
// either display the response message or the http status text
249
let errorMessage = result.statusText;
250
try {
251
const json = await result.json();
252
if (json.message) {
253
errorMessage = json.message;
254
}
255
} catch (err) {
256
// noop
257
}
258
this._logger.error(`Getting account info failed: ${errorMessage}`);
259
throw new Error(errorMessage);
260
}
261
}
262
263
public async sendAdditionalTelemetryInfo(session: vscode.AuthenticationSession): Promise<void> {
264
if (!vscode.env.isTelemetryEnabled) {
265
return;
266
}
267
const nocors = await this.isNoCorsEnvironment();
268
269
if (nocors) {
270
return;
271
}
272
273
if (this._type === AuthProviderType.github) {
274
return await this.checkUserDetails(session);
275
}
276
277
// GHES
278
await this.checkEnterpriseVersion(session.accessToken);
279
}
280
281
private async checkUserDetails(session: vscode.AuthenticationSession): Promise<void> {
282
let edu: string | undefined;
283
284
try {
285
const result = await fetching('https://education.github.com/api/user', {
286
logger: this._logger,
287
retryFallbacks: true,
288
expectJSON: true,
289
headers: {
290
Authorization: `token ${session.accessToken}`,
291
'faculty-check-preview': 'true',
292
'User-Agent': `${vscode.env.appName} (${vscode.env.appHost})`
293
}
294
});
295
296
if (result.ok) {
297
const json: { student: boolean; faculty: boolean } = await result.json();
298
edu = json.student
299
? 'student'
300
: json.faculty
301
? 'faculty'
302
: 'none';
303
} else {
304
this._logger.info(`Unable to resolve optional EDU details. Status: ${result.status} ${result.statusText}`);
305
edu = 'unknown';
306
}
307
} catch (e) {
308
this._logger.info(`Unable to resolve optional EDU details. Error: ${e}`);
309
edu = 'unknown';
310
}
311
312
/* __GDPR__
313
"session" : {
314
"owner": "TylerLeonhardt",
315
"isEdu": { "classification": "SystemMetaData", "purpose": "FeatureInsight" },
316
"isManaged": { "classification": "SystemMetaData", "purpose": "FeatureInsight" }
317
}
318
*/
319
this._telemetryReporter.sendTelemetryEvent('session', {
320
isEdu: edu,
321
// Apparently, this is how you tell if a user is an EMU...
322
isManaged: session.account.label.includes('_') ? 'true' : 'false'
323
});
324
}
325
326
private async checkEnterpriseVersion(token: string): Promise<void> {
327
try {
328
let version: string;
329
if (!isSupportedTarget(this._type, this._ghesUri)) {
330
const result = await fetching(this.getServerUri('/meta').toString(), {
331
logger: this._logger,
332
retryFallbacks: true,
333
expectJSON: true,
334
headers: {
335
Authorization: `token ${token}`,
336
'User-Agent': `${vscode.env.appName} (${vscode.env.appHost})`
337
}
338
});
339
340
if (!result.ok) {
341
return;
342
}
343
344
const json: { verifiable_password_authentication: boolean; installed_version: string } = await result.json();
345
version = json.installed_version;
346
} else {
347
version = 'hosted';
348
}
349
350
/* __GDPR__
351
"ghe-session" : {
352
"owner": "TylerLeonhardt",
353
"version": { "classification": "SystemMetaData", "purpose": "FeatureInsight" }
354
}
355
*/
356
this._telemetryReporter.sendTelemetryEvent('ghe-session', {
357
version
358
});
359
} catch {
360
// No-op
361
}
362
}
363
364
private processLoginError(error: Error): boolean {
365
if (error.message === CANCELLATION_ERROR) {
366
throw error;
367
}
368
this._logger.error(error.message ?? error);
369
return error.message === USER_CANCELLATION_ERROR;
370
}
371
}
372
373