Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/github-authentication/src/github.ts
5240 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 TelemetryReporter from '@vscode/extension-telemetry';
8
import { Keychain } from './common/keychain';
9
import { GitHubServer, IGitHubServer } from './githubServer';
10
import { PromiseAdapter, arrayEquals, promiseFromEvent } from './common/utils';
11
import { ExperimentationTelemetry } from './common/experimentationService';
12
import { Log } from './common/logger';
13
import { crypto } from './node/crypto';
14
import { TIMED_OUT_ERROR, USER_CANCELLATION_ERROR } from './common/errors';
15
import { GitHubSocialSignInProvider, isSocialSignInProvider } from './flows';
16
17
interface SessionData {
18
id: string;
19
account?: {
20
label?: string;
21
displayName?: string;
22
// Unfortunately, for some time the id was a number, so we need to support both.
23
// This can be removed once we are confident that all users have migrated to the new id.
24
id: string | number;
25
};
26
scopes: string[];
27
accessToken: string;
28
}
29
30
export enum AuthProviderType {
31
github = 'github',
32
githubEnterprise = 'github-enterprise'
33
}
34
35
interface GitHubAuthenticationProviderOptions extends vscode.AuthenticationProviderSessionOptions {
36
/**
37
* This is specific to GitHub and is used to determine which social sign-in provider to use.
38
* If not provided, the default (GitHub) is used which shows all options.
39
*
40
* Example: If you specify Google, then the sign-in flow will skip the initial page that asks you
41
* to choose how you want to sign in and will directly take you to the Google sign-in page.
42
*
43
* This allows us to show "Continue with Google" buttons in the product, rather than always
44
* leaving it up to the user to choose the social sign-in provider on the sign-in page.
45
*/
46
readonly provider?: GitHubSocialSignInProvider;
47
readonly extraAuthorizeParameters?: Record<string, string>;
48
}
49
50
function isGitHubAuthenticationProviderOptions(object: any): object is GitHubAuthenticationProviderOptions {
51
if (!object || typeof object !== 'object') {
52
throw new Error('Options are not an object');
53
}
54
if (object.provider !== undefined && !isSocialSignInProvider(object.provider)) {
55
throw new Error(`Provider is invalid: ${object.provider}`);
56
}
57
if (object.extraAuthorizeParameters !== undefined) {
58
if (!object.extraAuthorizeParameters || typeof object.extraAuthorizeParameters !== 'object') {
59
throw new Error('Extra parameters must be a record of string keys and string values.');
60
}
61
for (const [key, value] of Object.entries(object.extraAuthorizeParameters)) {
62
if (typeof key !== 'string' || typeof value !== 'string') {
63
throw new Error('Extra parameters must be a record of string keys and string values.');
64
}
65
}
66
}
67
return true;
68
}
69
70
export class UriEventHandler extends vscode.EventEmitter<vscode.Uri> implements vscode.UriHandler {
71
private readonly _pendingNonces = new Map<string, string[]>();
72
private readonly _codeExchangePromises = new Map<string, { promise: Promise<string>; cancel: vscode.EventEmitter<void> }>();
73
74
public handleUri(uri: vscode.Uri) {
75
this.fire(uri);
76
}
77
78
public async waitForCode(logger: Log, scopes: string, nonce: string, token: vscode.CancellationToken) {
79
const existingNonces = this._pendingNonces.get(scopes) || [];
80
this._pendingNonces.set(scopes, [...existingNonces, nonce]);
81
82
let codeExchangePromise = this._codeExchangePromises.get(scopes);
83
if (!codeExchangePromise) {
84
codeExchangePromise = promiseFromEvent(this.event, this.handleEvent(logger, scopes));
85
this._codeExchangePromises.set(scopes, codeExchangePromise);
86
}
87
88
try {
89
return await Promise.race([
90
codeExchangePromise.promise,
91
new Promise<string>((_, reject) => setTimeout(() => reject(TIMED_OUT_ERROR), 300_000)), // 5min timeout
92
promiseFromEvent<void, string>(token.onCancellationRequested, (_, __, reject) => { reject(USER_CANCELLATION_ERROR); }).promise
93
]);
94
} finally {
95
this._pendingNonces.delete(scopes);
96
codeExchangePromise?.cancel.fire();
97
this._codeExchangePromises.delete(scopes);
98
}
99
}
100
101
private handleEvent: (logger: Log, scopes: string) => PromiseAdapter<vscode.Uri, string> =
102
(logger: Log, scopes) => (uri, resolve, reject) => {
103
const query = new URLSearchParams(uri.query);
104
const code = query.get('code');
105
const nonce = query.get('nonce');
106
if (!code) {
107
reject(new Error('No code'));
108
return;
109
}
110
if (!nonce) {
111
reject(new Error('No nonce'));
112
return;
113
}
114
115
const acceptedNonces = this._pendingNonces.get(scopes) || [];
116
if (!acceptedNonces.includes(nonce)) {
117
// A common scenario of this happening is if you:
118
// 1. Trigger a sign in with one set of scopes
119
// 2. Before finishing 1, you trigger a sign in with a different set of scopes
120
// In this scenario we should just return and wait for the next UriHandler event
121
// to run as we are probably still waiting on the user to hit 'Continue'
122
logger.info('Nonce not found in accepted nonces. Skipping this execution...');
123
return;
124
}
125
126
resolve(code);
127
};
128
}
129
130
export class GitHubAuthenticationProvider implements vscode.AuthenticationProvider, vscode.Disposable {
131
private readonly _sessionChangeEmitter = new vscode.EventEmitter<vscode.AuthenticationProviderAuthenticationSessionsChangeEvent>();
132
private readonly _logger: Log;
133
private readonly _githubServer: IGitHubServer;
134
private readonly _telemetryReporter: ExperimentationTelemetry;
135
private readonly _keychain: Keychain;
136
private readonly _accountsSeen = new Set<string>();
137
private readonly _disposable: vscode.Disposable | undefined;
138
139
private _sessionsPromise: Promise<vscode.AuthenticationSession[]>;
140
141
constructor(
142
private readonly context: vscode.ExtensionContext,
143
uriHandler: UriEventHandler,
144
ghesUri?: vscode.Uri
145
) {
146
const { aiKey } = context.extension.packageJSON as { name: string; version: string; aiKey: string };
147
this._telemetryReporter = new ExperimentationTelemetry(context, new TelemetryReporter(aiKey));
148
149
const type = ghesUri ? AuthProviderType.githubEnterprise : AuthProviderType.github;
150
151
this._logger = new Log(type);
152
153
this._keychain = new Keychain(
154
this.context,
155
type === AuthProviderType.github
156
? `${type}.auth`
157
: `${ghesUri?.authority}${ghesUri?.path}.ghes.auth`,
158
this._logger);
159
160
this._githubServer = new GitHubServer(
161
this._logger,
162
this._telemetryReporter,
163
uriHandler,
164
context.extension.extensionKind,
165
ghesUri);
166
167
// Contains the current state of the sessions we have available.
168
this._sessionsPromise = this.readSessions().then((sessions) => {
169
// fire telemetry after a second to allow the workbench to focus on loading
170
setTimeout(() => sessions.forEach(s => this.afterSessionLoad(s)), 1000);
171
return sessions;
172
});
173
174
const supportedAuthorizationServers = ghesUri
175
? [vscode.Uri.joinPath(ghesUri, '/login/oauth')]
176
: [vscode.Uri.parse('https://github.com/login/oauth')];
177
this._disposable = vscode.Disposable.from(
178
this._telemetryReporter,
179
vscode.authentication.registerAuthenticationProvider(
180
type,
181
this._githubServer.friendlyName,
182
this,
183
{
184
supportsMultipleAccounts: true,
185
supportedAuthorizationServers
186
}
187
),
188
this.context.secrets.onDidChange(() => this.checkForUpdates())
189
);
190
}
191
192
dispose() {
193
this._disposable?.dispose();
194
}
195
196
get onDidChangeSessions() {
197
return this._sessionChangeEmitter.event;
198
}
199
200
async getSessions(scopes: string[] | undefined, options?: vscode.AuthenticationProviderSessionOptions): Promise<vscode.AuthenticationSession[]> {
201
// For GitHub scope list, order doesn't matter so we immediately sort the scopes
202
const sortedScopes = scopes?.sort() || [];
203
this._logger.info(`Getting sessions for ${sortedScopes.length ? sortedScopes.join(',') : 'all scopes'}...`);
204
const sessions = await this._sessionsPromise;
205
const accountFilteredSessions = options?.account
206
? sessions.filter(session => session.account.label === options.account?.label)
207
: sessions;
208
const finalSessions = sortedScopes.length
209
? accountFilteredSessions.filter(session => arrayEquals([...session.scopes].sort(), sortedScopes))
210
: accountFilteredSessions;
211
212
this._logger.info(`Got ${finalSessions.length} sessions for ${sortedScopes?.join(',') ?? 'all scopes'}...`);
213
return finalSessions;
214
}
215
216
private async afterSessionLoad(session: vscode.AuthenticationSession): Promise<void> {
217
// We only want to fire a telemetry if we haven't seen this account yet in this session.
218
if (!this._accountsSeen.has(session.account.id)) {
219
this._accountsSeen.add(session.account.id);
220
this._githubServer.sendAdditionalTelemetryInfo(session);
221
}
222
}
223
224
private async checkForUpdates() {
225
const previousSessions = await this._sessionsPromise;
226
this._sessionsPromise = this.readSessions();
227
const storedSessions = await this._sessionsPromise;
228
229
const added: vscode.AuthenticationSession[] = [];
230
const removed: vscode.AuthenticationSession[] = [];
231
232
storedSessions.forEach(session => {
233
const matchesExisting = previousSessions.some(s => s.id === session.id);
234
// Another window added a session to the keychain, add it to our state as well
235
if (!matchesExisting) {
236
this._logger.info('Adding session found in keychain');
237
added.push(session);
238
}
239
});
240
241
previousSessions.forEach(session => {
242
const matchesExisting = storedSessions.some(s => s.id === session.id);
243
// Another window has logged out, remove from our state
244
if (!matchesExisting) {
245
this._logger.info('Removing session no longer found in keychain');
246
removed.push(session);
247
}
248
});
249
250
if (added.length || removed.length) {
251
this._sessionChangeEmitter.fire({ added, removed, changed: [] });
252
}
253
}
254
255
private async readSessions(): Promise<vscode.AuthenticationSession[]> {
256
let sessionData: SessionData[];
257
try {
258
this._logger.info('Reading sessions from keychain...');
259
const storedSessions = await this._keychain.getToken();
260
if (!storedSessions) {
261
return [];
262
}
263
this._logger.info('Got stored sessions!');
264
265
try {
266
sessionData = JSON.parse(storedSessions);
267
} catch (e) {
268
await this._keychain.deleteToken();
269
throw e;
270
}
271
} catch (e) {
272
this._logger.error(`Error reading token: ${e}`);
273
return [];
274
}
275
276
// Unfortunately, we were using a number secretly for the account id for some time... this is due to a bad `any`.
277
// AuthenticationSession's account id is a string, so we need to detect when there is a number accountId and re-store
278
// the sessions to migrate away from the bad number usage.
279
// TODO@TylerLeonhardt: Remove this after we are confident that all users have migrated to the new id.
280
let seenNumberAccountId: boolean = false;
281
// TODO: eventually remove this Set because we should only have one session per set of scopes.
282
const scopesSeen = new Set<string>();
283
const sessionPromises = sessionData.map(async (session: SessionData): Promise<vscode.AuthenticationSession | undefined> => {
284
// For GitHub scope list, order doesn't matter so we immediately sort the scopes
285
const scopesStr = [...session.scopes].sort().join(' ');
286
let userInfo: { id: string; accountName: string } | undefined;
287
if (!session.account) {
288
try {
289
userInfo = await this._githubServer.getUserInfo(session.accessToken);
290
this._logger.info(`Verified session with the following scopes: ${scopesStr}`);
291
} catch (e) {
292
// Remove sessions that return unauthorized response
293
if (e.message === 'Unauthorized') {
294
return undefined;
295
}
296
}
297
}
298
299
this._logger.trace(`Read the following session from the keychain with the following scopes: ${scopesStr}`);
300
scopesSeen.add(scopesStr);
301
302
let accountId: string;
303
if (session.account?.id) {
304
if (typeof session.account.id === 'number') {
305
seenNumberAccountId = true;
306
}
307
accountId = `${session.account.id}`;
308
} else {
309
accountId = userInfo?.id ?? '<unknown>';
310
}
311
return {
312
id: session.id,
313
account: {
314
label: session.account
315
? session.account.label ?? session.account.displayName ?? '<unknown>'
316
: userInfo?.accountName ?? '<unknown>',
317
id: accountId
318
},
319
// we set this to session.scopes to maintain the original order of the scopes requested
320
// by the extension that called getSession()
321
scopes: session.scopes,
322
accessToken: session.accessToken
323
};
324
});
325
326
const verifiedSessions = (await Promise.allSettled(sessionPromises))
327
.filter(p => p.status === 'fulfilled')
328
.map(p => (p as PromiseFulfilledResult<vscode.AuthenticationSession | undefined>).value)
329
.filter(<T>(p?: T): p is T => Boolean(p));
330
331
this._logger.info(`Got ${verifiedSessions.length} verified sessions.`);
332
if (seenNumberAccountId || verifiedSessions.length !== sessionData.length) {
333
await this.storeSessions(verifiedSessions);
334
}
335
336
return verifiedSessions;
337
}
338
339
private async storeSessions(sessions: vscode.AuthenticationSession[]): Promise<void> {
340
this._logger.info(`Storing ${sessions.length} sessions...`);
341
this._sessionsPromise = Promise.resolve(sessions);
342
await this._keychain.setToken(JSON.stringify(sessions));
343
this._logger.info(`Stored ${sessions.length} sessions!`);
344
}
345
346
public async createSession(scopes: string[], options?: GitHubAuthenticationProviderOptions): Promise<vscode.AuthenticationSession> {
347
try {
348
// For GitHub scope list, order doesn't matter so we use a sorted scope to determine
349
// if we've got a session already.
350
const sortedScopes = [...scopes].sort();
351
352
/* __GDPR__
353
"login" : {
354
"owner": "TylerLeonhardt",
355
"comment": "Used to determine how much usage the GitHub Auth Provider gets.",
356
"scopes": { "classification": "PublicNonPersonalData", "purpose": "FeatureInsight", "comment": "Used to determine what scope combinations are being requested." }
357
}
358
*/
359
this._telemetryReporter?.sendTelemetryEvent('login', {
360
scopes: JSON.stringify(scopes),
361
});
362
363
if (options && !isGitHubAuthenticationProviderOptions(options)) {
364
throw new Error('Invalid options');
365
}
366
const sessions = await this._sessionsPromise;
367
const loginWith = options?.account?.label;
368
const signInProvider = options?.provider;
369
this._logger.info(`Logging in with${signInProvider ? ` ${signInProvider}, ` : ''} '${loginWith ? loginWith : 'any'}' account...`);
370
const scopeString = sortedScopes.join(' ');
371
const token = await this._githubServer.login(scopeString, signInProvider, options?.extraAuthorizeParameters, loginWith);
372
const session = await this.tokenToSession(token, scopes);
373
this.afterSessionLoad(session);
374
375
const sessionIndex = sessions.findIndex(s => s.account.id === session.account.id && arrayEquals([...s.scopes].sort(), sortedScopes));
376
const removed = new Array<vscode.AuthenticationSession>();
377
if (sessionIndex > -1) {
378
removed.push(...sessions.splice(sessionIndex, 1, session));
379
} else {
380
sessions.push(session);
381
}
382
await this.storeSessions(sessions);
383
384
this._sessionChangeEmitter.fire({ added: [session], removed, changed: [] });
385
386
this._logger.info('Login success!');
387
388
return session;
389
} catch (e) {
390
// If login was cancelled, do not notify user.
391
if (e === 'Cancelled' || e.message === 'Cancelled') {
392
/* __GDPR__
393
"loginCancelled" : { "owner": "TylerLeonhardt", "comment": "Used to determine how often users cancel the login flow." }
394
*/
395
this._telemetryReporter?.sendTelemetryEvent('loginCancelled');
396
throw e;
397
}
398
399
/* __GDPR__
400
"loginFailed" : { "owner": "TylerLeonhardt", "comment": "Used to determine how often users run into an error login flow." }
401
*/
402
this._telemetryReporter?.sendTelemetryEvent('loginFailed');
403
404
vscode.window.showErrorMessage(vscode.l10n.t('Sign in failed: {0}', `${e}`));
405
this._logger.error(e);
406
throw e;
407
}
408
}
409
410
private async tokenToSession(token: string, scopes: string[]): Promise<vscode.AuthenticationSession> {
411
const userInfo = await this._githubServer.getUserInfo(token);
412
return {
413
id: crypto.getRandomValues(new Uint32Array(2)).reduce((prev, curr) => prev += curr.toString(16), ''),
414
accessToken: token,
415
account: { label: userInfo.accountName, id: userInfo.id },
416
scopes
417
};
418
}
419
420
public async removeSession(id: string) {
421
try {
422
/* __GDPR__
423
"logout" : { "owner": "TylerLeonhardt", "comment": "Used to determine how often users log out of an account." }
424
*/
425
this._telemetryReporter?.sendTelemetryEvent('logout');
426
427
this._logger.info(`Logging out of ${id}`);
428
429
const sessions = await this._sessionsPromise;
430
const sessionIndex = sessions.findIndex(session => session.id === id);
431
if (sessionIndex > -1) {
432
const session = sessions[sessionIndex];
433
sessions.splice(sessionIndex, 1);
434
435
await this.storeSessions(sessions);
436
await this._githubServer.logout(session);
437
438
this._sessionChangeEmitter.fire({ added: [], removed: [session], changed: [] });
439
} else {
440
this._logger.error('Session not found');
441
}
442
} catch (e) {
443
/* __GDPR__
444
"logoutFailed" : { "owner": "TylerLeonhardt", "comment": "Used to determine how often logging out of an account fails." }
445
*/
446
this._telemetryReporter?.sendTelemetryEvent('logoutFailed');
447
448
vscode.window.showErrorMessage(vscode.l10n.t('Sign out failed: {0}', `${e}`));
449
this._logger.error(e);
450
throw e;
451
}
452
}
453
}
454
455