Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/github-authentication/src/githubServer.ts
3316 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
expectJSON: false,
183
method: 'DELETE',
184
headers: {
185
Accept: 'application/vnd.github+json',
186
Authorization: authHeader,
187
'X-GitHub-Api-Version': '2022-11-28',
188
'User-Agent': `${vscode.env.appName} (${vscode.env.appHost})`
189
},
190
body: JSON.stringify({ access_token: session.accessToken }),
191
});
192
193
if (result.status === 204) {
194
this._logger.trace(`Successfully deleted token from session (${session.id}) from server.`);
195
return;
196
}
197
198
try {
199
const body = await result.text();
200
throw new Error(body);
201
} catch (e) {
202
throw new Error(`${result.status} ${result.statusText}`);
203
}
204
} catch (e) {
205
this._logger.warn('Failed to delete token from server.' + (e.message ?? e));
206
}
207
}
208
209
private getServerUri(path: string = '') {
210
const apiUri = this.baseUri;
211
// github.com and Hosted GitHub Enterprise instances
212
if (isSupportedTarget(this._type, this._ghesUri)) {
213
return vscode.Uri.parse(`${apiUri.scheme}://api.${apiUri.authority}`).with({ path });
214
}
215
// GitHub Enterprise Server (aka on-prem)
216
return vscode.Uri.parse(`${apiUri.scheme}://${apiUri.authority}/api/v3${path}`);
217
}
218
219
public async getUserInfo(token: string): Promise<{ id: string; accountName: string }> {
220
let result;
221
try {
222
this._logger.info('Getting user info...');
223
result = await fetching(this.getServerUri('/user').toString(), {
224
logger: this._logger,
225
expectJSON: true,
226
headers: {
227
Authorization: `token ${token}`,
228
'User-Agent': `${vscode.env.appName} (${vscode.env.appHost})`
229
}
230
});
231
} catch (ex) {
232
this._logger.error(ex.message);
233
throw new Error(NETWORK_ERROR);
234
}
235
236
if (result.ok) {
237
try {
238
const json = await result.json() as { id: number; login: string };
239
this._logger.info('Got account info!');
240
return { id: `${json.id}`, accountName: json.login };
241
} catch (e) {
242
this._logger.error(`Unexpected error parsing response from GitHub: ${e.message ?? e}`);
243
throw e;
244
}
245
} else {
246
// either display the response message or the http status text
247
let errorMessage = result.statusText;
248
try {
249
const json = await result.json();
250
if (json.message) {
251
errorMessage = json.message;
252
}
253
} catch (err) {
254
// noop
255
}
256
this._logger.error(`Getting account info failed: ${errorMessage}`);
257
throw new Error(errorMessage);
258
}
259
}
260
261
public async sendAdditionalTelemetryInfo(session: vscode.AuthenticationSession): Promise<void> {
262
if (!vscode.env.isTelemetryEnabled) {
263
return;
264
}
265
const nocors = await this.isNoCorsEnvironment();
266
267
if (nocors) {
268
return;
269
}
270
271
if (this._type === AuthProviderType.github) {
272
return await this.checkUserDetails(session);
273
}
274
275
// GHES
276
await this.checkEnterpriseVersion(session.accessToken);
277
}
278
279
private async checkUserDetails(session: vscode.AuthenticationSession): Promise<void> {
280
let edu: string | undefined;
281
282
try {
283
const result = await fetching('https://education.github.com/api/user', {
284
logger: this._logger,
285
expectJSON: true,
286
headers: {
287
Authorization: `token ${session.accessToken}`,
288
'faculty-check-preview': 'true',
289
'User-Agent': `${vscode.env.appName} (${vscode.env.appHost})`
290
}
291
});
292
293
if (result.ok) {
294
const json: { student: boolean; faculty: boolean } = await result.json();
295
edu = json.student
296
? 'student'
297
: json.faculty
298
? 'faculty'
299
: 'none';
300
} else {
301
edu = 'unknown';
302
}
303
} catch (e) {
304
edu = 'unknown';
305
}
306
307
/* __GDPR__
308
"session" : {
309
"owner": "TylerLeonhardt",
310
"isEdu": { "classification": "SystemMetaData", "purpose": "FeatureInsight" },
311
"isManaged": { "classification": "SystemMetaData", "purpose": "FeatureInsight" }
312
}
313
*/
314
this._telemetryReporter.sendTelemetryEvent('session', {
315
isEdu: edu,
316
// Apparently, this is how you tell if a user is an EMU...
317
isManaged: session.account.label.includes('_') ? 'true' : 'false'
318
});
319
}
320
321
private async checkEnterpriseVersion(token: string): Promise<void> {
322
try {
323
let version: string;
324
if (!isSupportedTarget(this._type, this._ghesUri)) {
325
const result = await fetching(this.getServerUri('/meta').toString(), {
326
logger: this._logger,
327
expectJSON: true,
328
headers: {
329
Authorization: `token ${token}`,
330
'User-Agent': `${vscode.env.appName} (${vscode.env.appHost})`
331
}
332
});
333
334
if (!result.ok) {
335
return;
336
}
337
338
const json: { verifiable_password_authentication: boolean; installed_version: string } = await result.json();
339
version = json.installed_version;
340
} else {
341
version = 'hosted';
342
}
343
344
/* __GDPR__
345
"ghe-session" : {
346
"owner": "TylerLeonhardt",
347
"version": { "classification": "SystemMetaData", "purpose": "FeatureInsight" }
348
}
349
*/
350
this._telemetryReporter.sendTelemetryEvent('ghe-session', {
351
version
352
});
353
} catch {
354
// No-op
355
}
356
}
357
358
private processLoginError(error: Error): boolean {
359
if (error.message === CANCELLATION_ERROR) {
360
throw error;
361
}
362
this._logger.error(error.message ?? error);
363
return error.message === USER_CANCELLATION_ERROR;
364
}
365
}
366
367