Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/github-authentication/src/github.ts
3314 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
this._disposable = vscode.Disposable.from(
175
this._telemetryReporter,
176
vscode.authentication.registerAuthenticationProvider(
177
type,
178
this._githubServer.friendlyName,
179
this,
180
{
181
supportsMultipleAccounts: true,
182
supportedAuthorizationServers: [
183
ghesUri ?? vscode.Uri.parse('https://github.com/login/oauth')
184
]
185
}
186
),
187
this.context.secrets.onDidChange(() => this.checkForUpdates())
188
);
189
}
190
191
dispose() {
192
this._disposable?.dispose();
193
}
194
195
get onDidChangeSessions() {
196
return this._sessionChangeEmitter.event;
197
}
198
199
async getSessions(scopes: string[] | undefined, options?: vscode.AuthenticationProviderSessionOptions): Promise<vscode.AuthenticationSession[]> {
200
// For GitHub scope list, order doesn't matter so we immediately sort the scopes
201
const sortedScopes = scopes?.sort() || [];
202
this._logger.info(`Getting sessions for ${sortedScopes.length ? sortedScopes.join(',') : 'all scopes'}...`);
203
const sessions = await this._sessionsPromise;
204
const accountFilteredSessions = options?.account
205
? sessions.filter(session => session.account.label === options.account?.label)
206
: sessions;
207
const finalSessions = sortedScopes.length
208
? accountFilteredSessions.filter(session => arrayEquals([...session.scopes].sort(), sortedScopes))
209
: accountFilteredSessions;
210
211
this._logger.info(`Got ${finalSessions.length} sessions for ${sortedScopes?.join(',') ?? 'all scopes'}...`);
212
return finalSessions;
213
}
214
215
private async afterSessionLoad(session: vscode.AuthenticationSession): Promise<void> {
216
// We only want to fire a telemetry if we haven't seen this account yet in this session.
217
if (!this._accountsSeen.has(session.account.id)) {
218
this._accountsSeen.add(session.account.id);
219
this._githubServer.sendAdditionalTelemetryInfo(session);
220
}
221
}
222
223
private async checkForUpdates() {
224
const previousSessions = await this._sessionsPromise;
225
this._sessionsPromise = this.readSessions();
226
const storedSessions = await this._sessionsPromise;
227
228
const added: vscode.AuthenticationSession[] = [];
229
const removed: vscode.AuthenticationSession[] = [];
230
231
storedSessions.forEach(session => {
232
const matchesExisting = previousSessions.some(s => s.id === session.id);
233
// Another window added a session to the keychain, add it to our state as well
234
if (!matchesExisting) {
235
this._logger.info('Adding session found in keychain');
236
added.push(session);
237
}
238
});
239
240
previousSessions.forEach(session => {
241
const matchesExisting = storedSessions.some(s => s.id === session.id);
242
// Another window has logged out, remove from our state
243
if (!matchesExisting) {
244
this._logger.info('Removing session no longer found in keychain');
245
removed.push(session);
246
}
247
});
248
249
if (added.length || removed.length) {
250
this._sessionChangeEmitter.fire({ added, removed, changed: [] });
251
}
252
}
253
254
private async readSessions(): Promise<vscode.AuthenticationSession[]> {
255
let sessionData: SessionData[];
256
try {
257
this._logger.info('Reading sessions from keychain...');
258
const storedSessions = await this._keychain.getToken();
259
if (!storedSessions) {
260
return [];
261
}
262
this._logger.info('Got stored sessions!');
263
264
try {
265
sessionData = JSON.parse(storedSessions);
266
} catch (e) {
267
await this._keychain.deleteToken();
268
throw e;
269
}
270
} catch (e) {
271
this._logger.error(`Error reading token: ${e}`);
272
return [];
273
}
274
275
// Unfortunately, we were using a number secretly for the account id for some time... this is due to a bad `any`.
276
// AuthenticationSession's account id is a string, so we need to detect when there is a number accountId and re-store
277
// the sessions to migrate away from the bad number usage.
278
// TODO@TylerLeonhardt: Remove this after we are confident that all users have migrated to the new id.
279
let seenNumberAccountId: boolean = false;
280
// TODO: eventually remove this Set because we should only have one session per set of scopes.
281
const scopesSeen = new Set<string>();
282
const sessionPromises = sessionData.map(async (session: SessionData): Promise<vscode.AuthenticationSession | undefined> => {
283
// For GitHub scope list, order doesn't matter so we immediately sort the scopes
284
const scopesStr = [...session.scopes].sort().join(' ');
285
let userInfo: { id: string; accountName: string } | undefined;
286
if (!session.account) {
287
try {
288
userInfo = await this._githubServer.getUserInfo(session.accessToken);
289
this._logger.info(`Verified session with the following scopes: ${scopesStr}`);
290
} catch (e) {
291
// Remove sessions that return unauthorized response
292
if (e.message === 'Unauthorized') {
293
return undefined;
294
}
295
}
296
}
297
298
this._logger.trace(`Read the following session from the keychain with the following scopes: ${scopesStr}`);
299
scopesSeen.add(scopesStr);
300
301
let accountId: string;
302
if (session.account?.id) {
303
if (typeof session.account.id === 'number') {
304
seenNumberAccountId = true;
305
}
306
accountId = `${session.account.id}`;
307
} else {
308
accountId = userInfo?.id ?? '<unknown>';
309
}
310
return {
311
id: session.id,
312
account: {
313
label: session.account
314
? session.account.label ?? session.account.displayName ?? '<unknown>'
315
: userInfo?.accountName ?? '<unknown>',
316
id: accountId
317
},
318
// we set this to session.scopes to maintain the original order of the scopes requested
319
// by the extension that called getSession()
320
scopes: session.scopes,
321
accessToken: session.accessToken
322
};
323
});
324
325
const verifiedSessions = (await Promise.allSettled(sessionPromises))
326
.filter(p => p.status === 'fulfilled')
327
.map(p => (p as PromiseFulfilledResult<vscode.AuthenticationSession | undefined>).value)
328
.filter(<T>(p?: T): p is T => Boolean(p));
329
330
this._logger.info(`Got ${verifiedSessions.length} verified sessions.`);
331
if (seenNumberAccountId || verifiedSessions.length !== sessionData.length) {
332
await this.storeSessions(verifiedSessions);
333
}
334
335
return verifiedSessions;
336
}
337
338
private async storeSessions(sessions: vscode.AuthenticationSession[]): Promise<void> {
339
this._logger.info(`Storing ${sessions.length} sessions...`);
340
this._sessionsPromise = Promise.resolve(sessions);
341
await this._keychain.setToken(JSON.stringify(sessions));
342
this._logger.info(`Stored ${sessions.length} sessions!`);
343
}
344
345
public async createSession(scopes: string[], options?: GitHubAuthenticationProviderOptions): Promise<vscode.AuthenticationSession> {
346
try {
347
// For GitHub scope list, order doesn't matter so we use a sorted scope to determine
348
// if we've got a session already.
349
const sortedScopes = [...scopes].sort();
350
351
/* __GDPR__
352
"login" : {
353
"owner": "TylerLeonhardt",
354
"comment": "Used to determine how much usage the GitHub Auth Provider gets.",
355
"scopes": { "classification": "PublicNonPersonalData", "purpose": "FeatureInsight", "comment": "Used to determine what scope combinations are being requested." }
356
}
357
*/
358
this._telemetryReporter?.sendTelemetryEvent('login', {
359
scopes: JSON.stringify(scopes),
360
});
361
362
if (options && !isGitHubAuthenticationProviderOptions(options)) {
363
throw new Error('Invalid options');
364
}
365
const sessions = await this._sessionsPromise;
366
const loginWith = options?.account?.label;
367
const signInProvider = options?.provider;
368
this._logger.info(`Logging in with${signInProvider ? ` ${signInProvider}, ` : ''} '${loginWith ? loginWith : 'any'}' account...`);
369
const scopeString = sortedScopes.join(' ');
370
const token = await this._githubServer.login(scopeString, signInProvider, options?.extraAuthorizeParameters, loginWith);
371
const session = await this.tokenToSession(token, scopes);
372
this.afterSessionLoad(session);
373
374
const sessionIndex = sessions.findIndex(s => s.account.id === session.account.id && arrayEquals([...s.scopes].sort(), sortedScopes));
375
const removed = new Array<vscode.AuthenticationSession>();
376
if (sessionIndex > -1) {
377
removed.push(...sessions.splice(sessionIndex, 1, session));
378
} else {
379
sessions.push(session);
380
}
381
await this.storeSessions(sessions);
382
383
this._sessionChangeEmitter.fire({ added: [session], removed, changed: [] });
384
385
this._logger.info('Login success!');
386
387
return session;
388
} catch (e) {
389
// If login was cancelled, do not notify user.
390
if (e === 'Cancelled' || e.message === 'Cancelled') {
391
/* __GDPR__
392
"loginCancelled" : { "owner": "TylerLeonhardt", "comment": "Used to determine how often users cancel the login flow." }
393
*/
394
this._telemetryReporter?.sendTelemetryEvent('loginCancelled');
395
throw e;
396
}
397
398
/* __GDPR__
399
"loginFailed" : { "owner": "TylerLeonhardt", "comment": "Used to determine how often users run into an error login flow." }
400
*/
401
this._telemetryReporter?.sendTelemetryEvent('loginFailed');
402
403
vscode.window.showErrorMessage(vscode.l10n.t('Sign in failed: {0}', `${e}`));
404
this._logger.error(e);
405
throw e;
406
}
407
}
408
409
private async tokenToSession(token: string, scopes: string[]): Promise<vscode.AuthenticationSession> {
410
const userInfo = await this._githubServer.getUserInfo(token);
411
return {
412
id: crypto.getRandomValues(new Uint32Array(2)).reduce((prev, curr) => prev += curr.toString(16), ''),
413
accessToken: token,
414
account: { label: userInfo.accountName, id: userInfo.id },
415
scopes
416
};
417
}
418
419
public async removeSession(id: string) {
420
try {
421
/* __GDPR__
422
"logout" : { "owner": "TylerLeonhardt", "comment": "Used to determine how often users log out of an account." }
423
*/
424
this._telemetryReporter?.sendTelemetryEvent('logout');
425
426
this._logger.info(`Logging out of ${id}`);
427
428
const sessions = await this._sessionsPromise;
429
const sessionIndex = sessions.findIndex(session => session.id === id);
430
if (sessionIndex > -1) {
431
const session = sessions[sessionIndex];
432
sessions.splice(sessionIndex, 1);
433
434
await this.storeSessions(sessions);
435
await this._githubServer.logout(session);
436
437
this._sessionChangeEmitter.fire({ added: [], removed: [session], changed: [] });
438
} else {
439
this._logger.error('Session not found');
440
}
441
} catch (e) {
442
/* __GDPR__
443
"logoutFailed" : { "owner": "TylerLeonhardt", "comment": "Used to determine how often logging out of an account fails." }
444
*/
445
this._telemetryReporter?.sendTelemetryEvent('logoutFailed');
446
447
vscode.window.showErrorMessage(vscode.l10n.t('Sign out failed: {0}', `${e}`));
448
this._logger.error(e);
449
throw e;
450
}
451
}
452
}
453
454